diff --git a/.changeset/15204-8post-catalog-gates-retired.md b/.changeset/pre/15204-8post-catalog-gates-retired.md similarity index 100% rename from .changeset/15204-8post-catalog-gates-retired.md rename to .changeset/pre/15204-8post-catalog-gates-retired.md diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/pre/15211-ceremony-metadata-promotion.md similarity index 100% rename from .changeset/15211-ceremony-metadata-promotion.md rename to .changeset/pre/15211-ceremony-metadata-promotion.md diff --git a/.changeset/15211-cli-apply-reviewed-plan.md b/.changeset/pre/15211-cli-apply-reviewed-plan.md similarity index 100% rename from .changeset/15211-cli-apply-reviewed-plan.md rename to .changeset/pre/15211-cli-apply-reviewed-plan.md diff --git a/.changeset/22161-s17-widget-binding-fix-lines.md b/.changeset/pre/22161-s17-widget-binding-fix-lines.md similarity index 100% rename from .changeset/22161-s17-widget-binding-fix-lines.md rename to .changeset/pre/22161-s17-widget-binding-fix-lines.md diff --git a/.changeset/23183-app-item-read-by-package-id.md b/.changeset/pre/23183-app-item-read-by-package-id.md similarity index 100% rename from .changeset/23183-app-item-read-by-package-id.md rename to .changeset/pre/23183-app-item-read-by-package-id.md diff --git a/.changeset/23207-related-list-binding-split.md b/.changeset/pre/23207-related-list-binding-split.md similarity index 100% rename from .changeset/23207-related-list-binding-split.md rename to .changeset/pre/23207-related-list-binding-split.md diff --git a/.changeset/23210-cbp-extension-merged-master.md b/.changeset/pre/23210-cbp-extension-merged-master.md similarity index 100% rename from .changeset/23210-cbp-extension-merged-master.md rename to .changeset/pre/23210-cbp-extension-merged-master.md diff --git a/.changeset/23214-hono-query-multiplicity.md b/.changeset/pre/23214-hono-query-multiplicity.md similarity index 100% rename from .changeset/23214-hono-query-multiplicity.md rename to .changeset/pre/23214-hono-query-multiplicity.md diff --git a/.changeset/23215-inline-forward-parse.md b/.changeset/pre/23215-inline-forward-parse.md similarity index 100% rename from .changeset/23215-inline-forward-parse.md rename to .changeset/pre/23215-inline-forward-parse.md diff --git a/.changeset/23221-cascade-delete-hook-signal.md b/.changeset/pre/23221-cascade-delete-hook-signal.md similarity index 100% rename from .changeset/23221-cascade-delete-hook-signal.md rename to .changeset/pre/23221-cascade-delete-hook-signal.md diff --git a/.changeset/23235-export-group-key-classifier.md b/.changeset/pre/23235-export-group-key-classifier.md similarity index 100% rename from .changeset/23235-export-group-key-classifier.md rename to .changeset/pre/23235-export-group-key-classifier.md diff --git a/.changeset/23255-verify-flows-system-arm.md b/.changeset/pre/23255-verify-flows-system-arm.md similarity index 100% rename from .changeset/23255-verify-flows-system-arm.md rename to .changeset/pre/23255-verify-flows-system-arm.md diff --git a/.changeset/23265-dispatcher-delete-expected-version.md b/.changeset/pre/23265-dispatcher-delete-expected-version.md similarity index 100% rename from .changeset/23265-dispatcher-delete-expected-version.md rename to .changeset/pre/23265-dispatcher-delete-expected-version.md diff --git a/.changeset/23268-external-sharing-model-retired.md b/.changeset/pre/23268-external-sharing-model-retired.md similarity index 100% rename from .changeset/23268-external-sharing-model-retired.md rename to .changeset/pre/23268-external-sharing-model-retired.md diff --git a/.changeset/23268-skills-lint-rules-row.md b/.changeset/pre/23268-skills-lint-rules-row.md similarity index 100% rename from .changeset/23268-skills-lint-rules-row.md rename to .changeset/pre/23268-skills-lint-rules-row.md diff --git a/.changeset/23272-export-cbp-master-predicate.md b/.changeset/pre/23272-export-cbp-master-predicate.md similarity index 100% rename from .changeset/23272-export-cbp-master-predicate.md rename to .changeset/pre/23272-export-cbp-master-predicate.md diff --git a/.changeset/23279-parent-read-gate-silent-skip.md b/.changeset/pre/23279-parent-read-gate-silent-skip.md similarity index 100% rename from .changeset/23279-parent-read-gate-silent-skip.md rename to .changeset/pre/23279-parent-read-gate-silent-skip.md diff --git a/.changeset/23287-api-key-account-posture.md b/.changeset/pre/23287-api-key-account-posture.md similarity index 100% rename from .changeset/23287-api-key-account-posture.md rename to .changeset/pre/23287-api-key-account-posture.md diff --git a/.changeset/23291-packageless-read-serves-save-address.md b/.changeset/pre/23291-packageless-read-serves-save-address.md similarity index 100% rename from .changeset/23291-packageless-read-serves-save-address.md rename to .changeset/pre/23291-packageless-read-serves-save-address.md diff --git a/.changeset/23307-mail-attachments.md b/.changeset/pre/23307-mail-attachments.md similarity index 100% rename from .changeset/23307-mail-attachments.md rename to .changeset/pre/23307-mail-attachments.md diff --git a/.changeset/23310-serve-org-record-page.md b/.changeset/pre/23310-serve-org-record-page.md similarity index 100% rename from .changeset/23310-serve-org-record-page.md rename to .changeset/pre/23310-serve-org-record-page.md diff --git a/.changeset/23325-record-share-remedy-text.md b/.changeset/pre/23325-record-share-remedy-text.md similarity index 100% rename from .changeset/23325-record-share-remedy-text.md rename to .changeset/pre/23325-record-share-remedy-text.md diff --git a/.changeset/23335-held-admin-vendor-doors-refused.md b/.changeset/pre/23335-held-admin-vendor-doors-refused.md similarity index 100% rename from .changeset/23335-held-admin-vendor-doors-refused.md rename to .changeset/pre/23335-held-admin-vendor-doors-refused.md diff --git a/.changeset/23343-plugin-auth-import-account-invitation.md b/.changeset/pre/23343-plugin-auth-import-account-invitation.md similarity index 100% rename from .changeset/23343-plugin-auth-import-account-invitation.md rename to .changeset/pre/23343-plugin-auth-import-account-invitation.md diff --git a/.changeset/23343-plugin-email-account-invitation-template.md b/.changeset/pre/23343-plugin-email-account-invitation-template.md similarity index 100% rename from .changeset/23343-plugin-email-account-invitation-template.md rename to .changeset/pre/23343-plugin-email-account-invitation-template.md diff --git a/.changeset/23347-i18n-plugin-mounts-retired.md b/.changeset/pre/23347-i18n-plugin-mounts-retired.md similarity index 100% rename from .changeset/23347-i18n-plugin-mounts-retired.md rename to .changeset/pre/23347-i18n-plugin-mounts-retired.md diff --git a/.changeset/23350-cli-meta-package-flag.md b/.changeset/pre/23350-cli-meta-package-flag.md similarity index 100% rename from .changeset/23350-cli-meta-package-flag.md rename to .changeset/pre/23350-cli-meta-package-flag.md diff --git a/.changeset/23354-seed-claim-seed-rows.md b/.changeset/pre/23354-seed-claim-seed-rows.md similarity index 100% rename from .changeset/23354-seed-claim-seed-rows.md rename to .changeset/pre/23354-seed-claim-seed-rows.md diff --git a/.changeset/23354-seed-landed-rows.md b/.changeset/pre/23354-seed-landed-rows.md similarity index 100% rename from .changeset/23354-seed-landed-rows.md rename to .changeset/pre/23354-seed-landed-rows.md diff --git a/.changeset/23357-page-component-view-data-api-refused.md b/.changeset/pre/23357-page-component-view-data-api-refused.md similarity index 100% rename from .changeset/23357-page-component-view-data-api-refused.md rename to .changeset/pre/23357-page-component-view-data-api-refused.md diff --git a/.changeset/23359-uncomposed-security-remedy.md b/.changeset/pre/23359-uncomposed-security-remedy.md similarity index 100% rename from .changeset/23359-uncomposed-security-remedy.md rename to .changeset/pre/23359-uncomposed-security-remedy.md diff --git a/.changeset/23365-hono-declared-envelope.md b/.changeset/pre/23365-hono-declared-envelope.md similarity index 100% rename from .changeset/23365-hono-declared-envelope.md rename to .changeset/pre/23365-hono-declared-envelope.md diff --git a/.changeset/23367-api-key-lifecycle.md b/.changeset/pre/23367-api-key-lifecycle.md similarity index 100% rename from .changeset/23367-api-key-lifecycle.md rename to .changeset/pre/23367-api-key-lifecycle.md diff --git a/.changeset/23371-share-link-messages-audience-inputs.md b/.changeset/pre/23371-share-link-messages-audience-inputs.md similarity index 100% rename from .changeset/23371-share-link-messages-audience-inputs.md rename to .changeset/pre/23371-share-link-messages-audience-inputs.md diff --git a/.changeset/23372-share-link-email-audience-uniform-refusal.md b/.changeset/pre/23372-share-link-email-audience-uniform-refusal.md similarity index 100% rename from .changeset/23372-share-link-email-audience-uniform-refusal.md rename to .changeset/pre/23372-share-link-email-audience-uniform-refusal.md diff --git a/.changeset/23387-two-master-names-release.md b/.changeset/pre/23387-two-master-names-release.md similarity index 100% rename from .changeset/23387-two-master-names-release.md rename to .changeset/pre/23387-two-master-names-release.md diff --git a/.changeset/23388-page-component-rules-runtime-door.md b/.changeset/pre/23388-page-component-rules-runtime-door.md similarity index 100% rename from .changeset/23388-page-component-rules-runtime-door.md rename to .changeset/pre/23388-page-component-rules-runtime-door.md diff --git a/.changeset/23391-stored-failed-truth.md b/.changeset/pre/23391-stored-failed-truth.md similarity index 100% rename from .changeset/23391-stored-failed-truth.md rename to .changeset/pre/23391-stored-failed-truth.md diff --git a/.changeset/23395-page-form-empty-state.md b/.changeset/pre/23395-page-form-empty-state.md similarity index 100% rename from .changeset/23395-page-form-empty-state.md rename to .changeset/pre/23395-page-form-empty-state.md diff --git a/.changeset/23397-ceremony-builtin-positions.md b/.changeset/pre/23397-ceremony-builtin-positions.md similarity index 100% rename from .changeset/23397-ceremony-builtin-positions.md rename to .changeset/pre/23397-ceremony-builtin-positions.md diff --git a/.changeset/23399-ceremony-not-declared-column-drop.md b/.changeset/pre/23399-ceremony-not-declared-column-drop.md similarity index 100% rename from .changeset/23399-ceremony-not-declared-column-drop.md rename to .changeset/pre/23399-ceremony-not-declared-column-drop.md diff --git a/.changeset/23400-ownership-occupancy-gate.md b/.changeset/pre/23400-ownership-occupancy-gate.md similarity index 100% rename from .changeset/23400-ownership-occupancy-gate.md rename to .changeset/pre/23400-ownership-occupancy-gate.md diff --git a/.changeset/23404-permission-set-description-liveness.md b/.changeset/pre/23404-permission-set-description-liveness.md similarity index 100% rename from .changeset/23404-permission-set-description-liveness.md rename to .changeset/pre/23404-permission-set-description-liveness.md diff --git a/.changeset/23406-sealed-first-draft-remedy.md b/.changeset/pre/23406-sealed-first-draft-remedy.md similarity index 100% rename from .changeset/23406-sealed-first-draft-remedy.md rename to .changeset/pre/23406-sealed-first-draft-remedy.md diff --git a/.changeset/23415-drop-storage-other-rows.md b/.changeset/pre/23415-drop-storage-other-rows.md similarity index 100% rename from .changeset/23415-drop-storage-other-rows.md rename to .changeset/pre/23415-drop-storage-other-rows.md diff --git a/.changeset/23416-runtime-owner-overlay-refusal.md b/.changeset/pre/23416-runtime-owner-overlay-refusal.md similarity index 100% rename from .changeset/23416-runtime-owner-overlay-refusal.md rename to .changeset/pre/23416-runtime-owner-overlay-refusal.md diff --git a/.changeset/23421-retire-page-aliases-at-18.md b/.changeset/pre/23421-retire-page-aliases-at-18.md similarity index 100% rename from .changeset/23421-retire-page-aliases-at-18.md rename to .changeset/pre/23421-retire-page-aliases-at-18.md diff --git a/.changeset/23427-page-action-row-declarative-update-rules.md b/.changeset/pre/23427-page-action-row-declarative-update-rules.md similarity index 100% rename from .changeset/23427-page-action-row-declarative-update-rules.md rename to .changeset/pre/23427-page-action-row-declarative-update-rules.md diff --git a/.changeset/23429-sys-metadata-family-organization-column-retired.md b/.changeset/pre/23429-sys-metadata-family-organization-column-retired.md similarity index 100% rename from .changeset/23429-sys-metadata-family-organization-column-retired.md rename to .changeset/pre/23429-sys-metadata-family-organization-column-retired.md diff --git a/.changeset/23429-v18-boot-refusal.md b/.changeset/pre/23429-v18-boot-refusal.md similarity index 100% rename from .changeset/23429-v18-boot-refusal.md rename to .changeset/pre/23429-v18-boot-refusal.md diff --git a/.changeset/23430-grant-permission-set-id-retired.md b/.changeset/pre/23430-grant-permission-set-id-retired.md similarity index 100% rename from .changeset/23430-grant-permission-set-id-retired.md rename to .changeset/pre/23430-grant-permission-set-id-retired.md diff --git a/.changeset/23434-component-read-points-part-1.md b/.changeset/pre/23434-component-read-points-part-1.md similarity index 100% rename from .changeset/23434-component-read-points-part-1.md rename to .changeset/pre/23434-component-read-points-part-1.md diff --git a/.changeset/23434-component-read-points-part-2.md b/.changeset/pre/23434-component-read-points-part-2.md similarity index 100% rename from .changeset/23434-component-read-points-part-2.md rename to .changeset/pre/23434-component-read-points-part-2.md diff --git a/.changeset/23434-component-read-points-part-3.md b/.changeset/pre/23434-component-read-points-part-3.md similarity index 100% rename from .changeset/23434-component-read-points-part-3.md rename to .changeset/pre/23434-component-read-points-part-3.md diff --git a/.changeset/23434-objectui-read-points-to-ledger.md b/.changeset/pre/23434-objectui-read-points-to-ledger.md similarity index 100% rename from .changeset/23434-objectui-read-points-to-ledger.md rename to .changeset/pre/23434-objectui-read-points-to-ledger.md diff --git a/.changeset/23434-sha-less-read-points.md b/.changeset/pre/23434-sha-less-read-points.md similarity index 100% rename from .changeset/23434-sha-less-read-points.md rename to .changeset/pre/23434-sha-less-read-points.md diff --git a/.changeset/23455-key-admission-posture-required.md b/.changeset/pre/23455-key-admission-posture-required.md similarity index 100% rename from .changeset/23455-key-admission-posture-required.md rename to .changeset/pre/23455-key-admission-posture-required.md diff --git a/.changeset/23462-dispatcher-patch-expected-version.md b/.changeset/pre/23462-dispatcher-patch-expected-version.md similarity index 100% rename from .changeset/23462-dispatcher-patch-expected-version.md rename to .changeset/pre/23462-dispatcher-patch-expected-version.md diff --git a/.changeset/23473-uninstall-shared-object-refusal.md b/.changeset/pre/23473-uninstall-shared-object-refusal.md similarity index 100% rename from .changeset/23473-uninstall-shared-object-refusal.md rename to .changeset/pre/23473-uninstall-shared-object-refusal.md diff --git a/.changeset/23474-email-refusal-cost.md b/.changeset/pre/23474-email-refusal-cost.md similarity index 100% rename from .changeset/23474-email-refusal-cost.md rename to .changeset/pre/23474-email-refusal-cost.md diff --git a/.changeset/23477-revoke-reason-vocabulary.md b/.changeset/pre/23477-revoke-reason-vocabulary.md similarity index 100% rename from .changeset/23477-revoke-reason-vocabulary.md rename to .changeset/pre/23477-revoke-reason-vocabulary.md diff --git a/.changeset/23479-meta-delete-noop-line.md b/.changeset/pre/23479-meta-delete-noop-line.md similarity index 100% rename from .changeset/23479-meta-delete-noop-line.md rename to .changeset/pre/23479-meta-delete-noop-line.md diff --git a/.changeset/23480-cli-meta-register-force.md b/.changeset/pre/23480-cli-meta-register-force.md similarity index 100% rename from .changeset/23480-cli-meta-register-force.md rename to .changeset/pre/23480-cli-meta-register-force.md diff --git a/.changeset/23487-retire-overdue-conversions.md b/.changeset/pre/23487-retire-overdue-conversions.md similarity index 100% rename from .changeset/23487-retire-overdue-conversions.md rename to .changeset/pre/23487-retire-overdue-conversions.md diff --git a/.changeset/23488-admin-scope-binding-authoring-retired.md b/.changeset/pre/23488-admin-scope-binding-authoring-retired.md similarity index 100% rename from .changeset/23488-admin-scope-binding-authoring-retired.md rename to .changeset/pre/23488-admin-scope-binding-authoring-retired.md diff --git a/.changeset/23491-patch-data-request-gate.md b/.changeset/pre/23491-patch-data-request-gate.md similarity index 100% rename from .changeset/23491-patch-data-request-gate.md rename to .changeset/pre/23491-patch-data-request-gate.md diff --git a/.changeset/23492-empty-version-token-refused.md b/.changeset/pre/23492-empty-version-token-refused.md similarity index 100% rename from .changeset/23492-empty-version-token-refused.md rename to .changeset/pre/23492-empty-version-token-refused.md diff --git a/.changeset/23495-draft-doors-one-address.md b/.changeset/pre/23495-draft-doors-one-address.md similarity index 100% rename from .changeset/23495-draft-doors-one-address.md rename to .changeset/pre/23495-draft-doors-one-address.md diff --git a/.changeset/23501-automation-door-package-remedy.md b/.changeset/pre/23501-automation-door-package-remedy.md similarity index 100% rename from .changeset/23501-automation-door-package-remedy.md rename to .changeset/pre/23501-automation-door-package-remedy.md diff --git a/.changeset/23505-extension-refusal-409.md b/.changeset/pre/23505-extension-refusal-409.md similarity index 100% rename from .changeset/23505-extension-refusal-409.md rename to .changeset/pre/23505-extension-refusal-409.md diff --git a/.changeset/23509-mcp-coded-refusal.md b/.changeset/pre/23509-mcp-coded-refusal.md similarity index 100% rename from .changeset/23509-mcp-coded-refusal.md rename to .changeset/pre/23509-mcp-coded-refusal.md diff --git a/.changeset/23511-duplicate-package-unprefixed-object.md b/.changeset/pre/23511-duplicate-package-unprefixed-object.md similarity index 100% rename from .changeset/23511-duplicate-package-unprefixed-object.md rename to .changeset/pre/23511-duplicate-package-unprefixed-object.md diff --git a/.changeset/23513-grant-remedy-plan-file.md b/.changeset/pre/23513-grant-remedy-plan-file.md similarity index 100% rename from .changeset/23513-grant-remedy-plan-file.md rename to .changeset/pre/23513-grant-remedy-plan-file.md diff --git a/.changeset/23514-sentinel-owner-save.md b/.changeset/pre/23514-sentinel-owner-save.md similarity index 100% rename from .changeset/23514-sentinel-owner-save.md rename to .changeset/pre/23514-sentinel-owner-save.md diff --git a/.changeset/23527-duplicate-permission-field-keys.md b/.changeset/pre/23527-duplicate-permission-field-keys.md similarity index 100% rename from .changeset/23527-duplicate-permission-field-keys.md rename to .changeset/pre/23527-duplicate-permission-field-keys.md diff --git a/.changeset/23529-mcp-action-tools-withhold-permission-details.md b/.changeset/pre/23529-mcp-action-tools-withhold-permission-details.md similarity index 100% rename from .changeset/23529-mcp-action-tools-withhold-permission-details.md rename to .changeset/pre/23529-mcp-action-tools-withhold-permission-details.md diff --git a/.changeset/23531-hono-conflict-body.md b/.changeset/pre/23531-hono-conflict-body.md similarity index 100% rename from .changeset/23531-hono-conflict-body.md rename to .changeset/pre/23531-hono-conflict-body.md diff --git a/.changeset/23531-hono-conflict-current-record.md b/.changeset/pre/23531-hono-conflict-current-record.md similarity index 100% rename from .changeset/23531-hono-conflict-current-record.md rename to .changeset/pre/23531-hono-conflict-current-record.md diff --git a/.changeset/23532-hono-undeclared-500-leak-filter.md b/.changeset/pre/23532-hono-undeclared-500-leak-filter.md similarity index 100% rename from .changeset/23532-hono-undeclared-500-leak-filter.md rename to .changeset/pre/23532-hono-undeclared-500-leak-filter.md diff --git a/.changeset/23537-duplicate-package-reference-collision.md b/.changeset/pre/23537-duplicate-package-reference-collision.md similarity index 100% rename from .changeset/23537-duplicate-package-reference-collision.md rename to .changeset/pre/23537-duplicate-package-reference-collision.md diff --git a/.changeset/23538-duplicate-names-in-step.md b/.changeset/pre/23538-duplicate-names-in-step.md similarity index 100% rename from .changeset/23538-duplicate-names-in-step.md rename to .changeset/pre/23538-duplicate-names-in-step.md diff --git a/.changeset/23539-references-permission-fields-keys.md b/.changeset/pre/23539-references-permission-fields-keys.md similarity index 100% rename from .changeset/23539-references-permission-fields-keys.md rename to .changeset/pre/23539-references-permission-fields-keys.md diff --git a/.changeset/23542-walled-binding-remedy.md b/.changeset/pre/23542-walled-binding-remedy.md similarity index 100% rename from .changeset/23542-walled-binding-remedy.md rename to .changeset/pre/23542-walled-binding-remedy.md diff --git a/.changeset/23543-f1-retire-mongo-driver-alias.md b/.changeset/pre/23543-f1-retire-mongo-driver-alias.md similarity index 100% rename from .changeset/23543-f1-retire-mongo-driver-alias.md rename to .changeset/pre/23543-f1-retire-mongo-driver-alias.md diff --git a/.changeset/23543-f3-retire-wait-connector-config-lift.md b/.changeset/pre/23543-f3-retire-wait-connector-config-lift.md similarity index 100% rename from .changeset/23543-f3-retire-wait-connector-config-lift.md rename to .changeset/pre/23543-f3-retire-wait-connector-config-lift.md diff --git a/.changeset/23543-f5-jsx-retire-page-kind-jsx.md b/.changeset/pre/23543-f5-jsx-retire-page-kind-jsx.md similarity index 100% rename from .changeset/23543-f5-jsx-retire-page-kind-jsx.md rename to .changeset/pre/23543-f5-jsx-retire-page-kind-jsx.md diff --git a/.changeset/23546-trail-doors-setup-access.md b/.changeset/pre/23546-trail-doors-setup-access.md similarity index 100% rename from .changeset/23546-trail-doors-setup-access.md rename to .changeset/pre/23546-trail-doors-setup-access.md diff --git a/.changeset/23549-fls-empty-object-part.md b/.changeset/pre/23549-fls-empty-object-part.md similarity index 100% rename from .changeset/23549-fls-empty-object-part.md rename to .changeset/pre/23549-fls-empty-object-part.md diff --git a/.changeset/23556-approval-request-names-record.md b/.changeset/pre/23556-approval-request-names-record.md similarity index 100% rename from .changeset/23556-approval-request-names-record.md rename to .changeset/pre/23556-approval-request-names-record.md diff --git a/.changeset/23558-ceremony-dialect-resolver.md b/.changeset/pre/23558-ceremony-dialect-resolver.md similarity index 100% rename from .changeset/23558-ceremony-dialect-resolver.md rename to .changeset/pre/23558-ceremony-dialect-resolver.md diff --git a/.changeset/23562-read-only-arms-journal-mode-note.md b/.changeset/pre/23562-read-only-arms-journal-mode-note.md similarity index 100% rename from .changeset/23562-read-only-arms-journal-mode-note.md rename to .changeset/pre/23562-read-only-arms-journal-mode-note.md diff --git a/.changeset/23573-serve-no-storage-loud.md b/.changeset/pre/23573-serve-no-storage-loud.md similarity index 100% rename from .changeset/23573-serve-no-storage-loud.md rename to .changeset/pre/23573-serve-no-storage-loud.md diff --git a/.changeset/23574-data-request-contract-remainder.md b/.changeset/pre/23574-data-request-contract-remainder.md similarity index 100% rename from .changeset/23574-data-request-contract-remainder.md rename to .changeset/pre/23574-data-request-contract-remainder.md diff --git a/.changeset/23577-sealed-write-one-sentence.md b/.changeset/pre/23577-sealed-write-one-sentence.md similarity index 100% rename from .changeset/23577-sealed-write-one-sentence.md rename to .changeset/pre/23577-sealed-write-one-sentence.md diff --git a/.changeset/23586-occ-conflict-caller-visibility.md b/.changeset/pre/23586-occ-conflict-caller-visibility.md similarity index 100% rename from .changeset/23586-occ-conflict-caller-visibility.md rename to .changeset/pre/23586-occ-conflict-caller-visibility.md diff --git a/.changeset/23587-hono-structured-arm-fields.md b/.changeset/pre/23587-hono-structured-arm-fields.md similarity index 100% rename from .changeset/23587-hono-structured-arm-fields.md rename to .changeset/pre/23587-hono-structured-arm-fields.md diff --git a/.changeset/23587-hono-unique-violation.md b/.changeset/pre/23587-hono-unique-violation.md similarity index 100% rename from .changeset/23587-hono-unique-violation.md rename to .changeset/pre/23587-hono-unique-violation.md diff --git a/.changeset/23594-subject-title-reader-fls.md b/.changeset/pre/23594-subject-title-reader-fls.md similarity index 100% rename from .changeset/23594-subject-title-reader-fls.md rename to .changeset/pre/23594-subject-title-reader-fls.md diff --git a/.changeset/23598-plugin-auth-import-invite-delivery-failure.md b/.changeset/pre/23598-plugin-auth-import-invite-delivery-failure.md similarity index 100% rename from .changeset/23598-plugin-auth-import-invite-delivery-failure.md rename to .changeset/pre/23598-plugin-auth-import-invite-delivery-failure.md diff --git a/.changeset/23615-grant-unique-index-skill.md b/.changeset/pre/23615-grant-unique-index-skill.md similarity index 100% rename from .changeset/23615-grant-unique-index-skill.md rename to .changeset/pre/23615-grant-unique-index-skill.md diff --git a/.changeset/23618-approval-title-family-served-fields.md b/.changeset/pre/23618-approval-title-family-served-fields.md similarity index 100% rename from .changeset/23618-approval-title-family-served-fields.md rename to .changeset/pre/23618-approval-title-family-served-fields.md diff --git a/.changeset/23622-read-only-arms-journal-note-remainder.md b/.changeset/pre/23622-read-only-arms-journal-note-remainder.md similarity index 100% rename from .changeset/23622-read-only-arms-journal-note-remainder.md rename to .changeset/pre/23622-read-only-arms-journal-note-remainder.md diff --git a/.changeset/23623-no-server-banner-urls.md b/.changeset/pre/23623-no-server-banner-urls.md similarity index 100% rename from .changeset/23623-no-server-banner-urls.md rename to .changeset/pre/23623-no-server-banner-urls.md diff --git a/.changeset/23630-promote-doors-d99.md b/.changeset/pre/23630-promote-doors-d99.md similarity index 100% rename from .changeset/23630-promote-doors-d99.md rename to .changeset/pre/23630-promote-doors-d99.md diff --git a/.changeset/23632-form-section-labels.md b/.changeset/pre/23632-form-section-labels.md similarity index 100% rename from .changeset/23632-form-section-labels.md rename to .changeset/pre/23632-form-section-labels.md diff --git a/.changeset/23635-sqlite-wasm-background-flush-failure.md b/.changeset/pre/23635-sqlite-wasm-background-flush-failure.md similarity index 100% rename from .changeset/23635-sqlite-wasm-background-flush-failure.md rename to .changeset/pre/23635-sqlite-wasm-background-flush-failure.md diff --git a/.changeset/23636-no-storage-hydration-error.md b/.changeset/pre/23636-no-storage-hydration-error.md similarity index 100% rename from .changeset/23636-no-storage-hydration-error.md rename to .changeset/pre/23636-no-storage-hydration-error.md diff --git a/.changeset/23637-lookup-target-title-row-access.md b/.changeset/pre/23637-lookup-target-title-row-access.md similarity index 100% rename from .changeset/23637-lookup-target-title-row-access.md rename to .changeset/pre/23637-lookup-target-title-row-access.md diff --git a/.changeset/23639-readable-ids-drivers.md b/.changeset/pre/23639-readable-ids-drivers.md similarity index 100% rename from .changeset/23639-readable-ids-drivers.md rename to .changeset/pre/23639-readable-ids-drivers.md diff --git a/.changeset/23644-failed-boot-rollback.md b/.changeset/pre/23644-failed-boot-rollback.md similarity index 100% rename from .changeset/23644-failed-boot-rollback.md rename to .changeset/pre/23644-failed-boot-rollback.md diff --git a/.changeset/23645-delete-heal-boot-hydrated.md b/.changeset/pre/23645-delete-heal-boot-hydrated.md similarity index 100% rename from .changeset/23645-delete-heal-boot-hydrated.md rename to .changeset/pre/23645-delete-heal-boot-hydrated.md diff --git a/.changeset/23646-adopt-orphans-object-registry-owner.md b/.changeset/pre/23646-adopt-orphans-object-registry-owner.md similarity index 100% rename from .changeset/23646-adopt-orphans-object-registry-owner.md rename to .changeset/pre/23646-adopt-orphans-object-registry-owner.md diff --git a/.changeset/23648-sqlite-wasm-sql-driver.md b/.changeset/pre/23648-sqlite-wasm-sql-driver.md similarity index 100% rename from .changeset/23648-sqlite-wasm-sql-driver.md rename to .changeset/pre/23648-sqlite-wasm-sql-driver.md diff --git a/.changeset/23649-datasource-mapping-before-sync.md b/.changeset/pre/23649-datasource-mapping-before-sync.md similarity index 100% rename from .changeset/23649-datasource-mapping-before-sync.md rename to .changeset/pre/23649-datasource-mapping-before-sync.md diff --git a/.changeset/23657-draft-save-one-package-draft.md b/.changeset/pre/23657-draft-save-one-package-draft.md similarity index 100% rename from .changeset/23657-draft-save-one-package-draft.md rename to .changeset/pre/23657-draft-save-one-package-draft.md diff --git a/.changeset/23658-client-publish-package.md b/.changeset/pre/23658-client-publish-package.md similarity index 100% rename from .changeset/23658-client-publish-package.md rename to .changeset/pre/23658-client-publish-package.md diff --git a/.changeset/23661-runtime-object-metadata-copy.md b/.changeset/pre/23661-runtime-object-metadata-copy.md similarity index 100% rename from .changeset/23661-runtime-object-metadata-copy.md rename to .changeset/pre/23661-runtime-object-metadata-copy.md diff --git a/.changeset/23666-ceremony-platform-global-column-drop.md b/.changeset/pre/23666-ceremony-platform-global-column-drop.md similarity index 100% rename from .changeset/23666-ceremony-platform-global-column-drop.md rename to .changeset/pre/23666-ceremony-platform-global-column-drop.md diff --git a/.changeset/23673-parent-gate-readable-ids.md b/.changeset/pre/23673-parent-gate-readable-ids.md similarity index 100% rename from .changeset/23673-parent-gate-readable-ids.md rename to .changeset/pre/23673-parent-gate-readable-ids.md diff --git a/.changeset/23679-memory-readable-ids-hashed-once.md b/.changeset/pre/23679-memory-readable-ids-hashed-once.md similarity index 100% rename from .changeset/23679-memory-readable-ids-hashed-once.md rename to .changeset/pre/23679-memory-readable-ids-hashed-once.md diff --git a/.changeset/23684-drop-gate-host-tables.md b/.changeset/pre/23684-drop-gate-host-tables.md similarity index 100% rename from .changeset/23684-drop-gate-host-tables.md rename to .changeset/pre/23684-drop-gate-host-tables.md diff --git a/.changeset/23686-dispatcher-unique-violation.md b/.changeset/pre/23686-dispatcher-unique-violation.md similarity index 100% rename from .changeset/23686-dispatcher-unique-violation.md rename to .changeset/pre/23686-dispatcher-unique-violation.md diff --git a/.changeset/23689-env-kernel-runtime-object-copy.md b/.changeset/pre/23689-env-kernel-runtime-object-copy.md similarity index 100% rename from .changeset/23689-env-kernel-runtime-object-copy.md rename to .changeset/pre/23689-env-kernel-runtime-object-copy.md diff --git a/.changeset/23690-adopt-orphans-draft-view.md b/.changeset/pre/23690-adopt-orphans-draft-view.md similarity index 100% rename from .changeset/23690-adopt-orphans-draft-view.md rename to .changeset/pre/23690-adopt-orphans-draft-view.md diff --git a/.changeset/23696-sqlite-uncreatable-path.md b/.changeset/pre/23696-sqlite-uncreatable-path.md similarity index 100% rename from .changeset/23696-sqlite-uncreatable-path.md rename to .changeset/pre/23696-sqlite-uncreatable-path.md diff --git a/.changeset/23701-mcp-unique-violation.md b/.changeset/pre/23701-mcp-unique-violation.md similarity index 100% rename from .changeset/23701-mcp-unique-violation.md rename to .changeset/pre/23701-mcp-unique-violation.md diff --git a/.changeset/23702-sandbox-relay-duplicate.md b/.changeset/pre/23702-sandbox-relay-duplicate.md similarity index 100% rename from .changeset/23702-sandbox-relay-duplicate.md rename to .changeset/pre/23702-sandbox-relay-duplicate.md diff --git a/.changeset/23715-strict-draft-read-address.md b/.changeset/pre/23715-strict-draft-read-address.md similarity index 100% rename from .changeset/23715-strict-draft-read-address.md rename to .changeset/pre/23715-strict-draft-read-address.md diff --git a/.changeset/23718-sandbox-plain-object-throw.md b/.changeset/pre/23718-sandbox-plain-object-throw.md similarity index 100% rename from .changeset/23718-sandbox-plain-object-throw.md rename to .changeset/pre/23718-sandbox-plain-object-throw.md diff --git a/.changeset/23734-heavy-flow-bounded-modal.md b/.changeset/pre/23734-heavy-flow-bounded-modal.md similarity index 100% rename from .changeset/23734-heavy-flow-bounded-modal.md rename to .changeset/pre/23734-heavy-flow-bounded-modal.md diff --git a/.changeset/console-1f23d5b070fa.md b/.changeset/pre/console-1f23d5b070fa.md similarity index 100% rename from .changeset/console-1f23d5b070fa.md rename to .changeset/pre/console-1f23d5b070fa.md diff --git a/.changeset/console-f96f771140f1.md b/.changeset/pre/console-f96f771140f1.md similarity index 100% rename from .changeset/console-f96f771140f1.md rename to .changeset/pre/console-f96f771140f1.md diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index d19d956f0ab..0ad575f394a 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,67 @@ # @objectstack/example-crm +## 4.0.100-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [249130f] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [395ca21] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/service-i18n@18.0.0-next.2 + ## 4.0.100-next.1 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 6a09eae55f3..8f3cf45cc34 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.100-next.1", + "version": "4.0.100-next.2", "description": "Minimal CRM example \u2014 a smoke-test workspace that exercises the metadata loading pipeline (objects \u2192 views \u2192 app \u2192 dashboard \u2192 hook \u2192 flow \u2192 seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-multi-package/CHANGELOG.md b/examples/app-multi-package/CHANGELOG.md index 75564356e58..405cd280a06 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/example-multi-package +## 0.0.7-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 0.0.7-next.1 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index 45fdfd350d5..ac63f6882b8 100644 --- a/examples/app-multi-package/package.json +++ b/examples/app-multi-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-multi-package", - "version": "0.0.7-next.1", + "version": "0.0.7-next.2", "description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`", "license": "Apache-2.0", "private": true, diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index c0c6f17eb22..9e6ccbf2820 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,75 @@ # @objectstack/example-showcase +## 0.3.22-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [249130f] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [395ca21] +- Updated dependencies [53646fc] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/service-datasource@18.0.0-next.2 + - @objectstack/service-i18n@18.0.0-next.2 + - @objectstack/cloud-connection@18.0.0-next.2 + - @objectstack/driver-sql@18.0.0-next.2 + - @objectstack/connector-mcp@18.0.0-next.2 + - @objectstack/connector-openapi@18.0.0-next.2 + - @objectstack/connector-rest@18.0.0-next.2 + - @objectstack/connector-slack@18.0.0-next.2 + ## 0.3.22-next.1 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 3c82983c10e..2e729ce51bf 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.22-next.1", + "version": "0.3.22-next.2", "description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, analytics). Built for demonstration, debugging, and coverage-driven verification.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-todo/CHANGELOG.md b/examples/app-todo/CHANGELOG.md index fce2786c0b6..c2e3728ed57 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,85 @@ # @objectstack/example-todo +## 4.0.100-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [249130f] +- Updated dependencies [a1285f2] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [b1cfa3d] +- Updated dependencies [23e689d] +- Updated dependencies [0fb55fc] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [c7dd48a] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [361a1c4] +- Updated dependencies [3bc407d] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/service-i18n@18.0.0-next.2 + - @objectstack/client@18.0.0-next.2 + - @objectstack/mcp@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/driver-sqlite-wasm@18.0.0-next.2 + - @objectstack/knowledge-memory@18.0.0-next.2 + - @objectstack/service-knowledge@18.0.0-next.2 + ## 4.0.100-next.1 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 170d7882a3a..9bd21e2ed00 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.100-next.1", + "version": "4.0.100-next.2", "description": "Example Todo App using ObjectStack Protocol", "license": "Apache-2.0", "private": true, diff --git a/examples/embed-objectql/CHANGELOG.md b/examples/embed-objectql/CHANGELOG.md index bde58f45835..4a3d3a30463 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/example-embed-objectql +## 0.0.40-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [989aa48] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [f77ba41] +- Updated dependencies [76560a1] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/driver-memory@18.0.0-next.2 + ## 0.0.40-next.1 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 89f694b5610..37f22cb7983 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.40-next.1", + "version": "0.0.40-next.2", "private": true, "description": "Embed the ObjectQL engine as a plain library via @objectstack/objectql/core — no kernel, no plugins, no metadata protocol (ADR-0076).", "type": "module", diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index 74aed1cd428..bf0b5193f39 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,144 @@ # @objectstack/hono +## 18.0.0-next.2 + +### Minor Changes + +- cb9841a: fix(hono,runtime)!: `createHonoApp` hands the dispatcher a repeated query name as an array, so the dispatcher's arity rules refuse it instead of answering its last value; `/packages` refuses a repeated `keepData` or `overwrite` + + `@objectstack/hono`'s `createHonoApp` catch-all built the dispatcher's query by copying `URLSearchParams` into an object, so a name sent twice reached `HttpDispatcher.dispatch()` as its LAST value and the first was dropped without a word. Every arity rule the dispatcher declares was therefore unreachable on a `createHonoApp` host: `GET /meta/app/crm?package=a&package=b` was answered `200` for package `b`, where the same request through `@objectstack/plugin-hono-server` is refused. The catch-all now reads the query the way `@objectstack/plugin-hono-server` does: a name sent once is the string it always was, a repeated name is the array of its values, and the object is built without dynamic property writes, so a `__proto__` name is an ordinary own key. + + Two `/packages` write flags read a repeat as absent, and are now refused before the registry or the protocol is touched, as `enabled` and `version` already are. `DELETE /packages/:id?keepData=true&keepData=true` dropped the package's object tables, because the protocol keeps storage only for `keepData === true`, and was answered `200`. `POST /packages?overwrite=true&overwrite=true` answered `409` "already exists" for a package one `overwrite=true` replaces. Both doors were reached that way through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats, and would have been through `createHonoApp` from this change. + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you: send each single-valued query name once. On a `createHonoApp` host, a request that repeats one is now refused `400` in the dispatcher's envelope, `{ success: false, error: { code, message } }`, with the code and sentence the same request gets through `@objectstack/plugin-hono-server` (`VALIDATION_ERROR`, "The "NAME" query parameter was supplied N times…"; `INVALID_FILTER` on the `/data` filter slot), where it used to be answered with the last value. On every host, `DELETE /packages/:id` refuses a repeated `keepData` and `POST /packages` a repeated `overwrite`, with that same `VALIDATION_ERROR` and sentence. A name sent once is answered as before; genuinely multi-valued names such as `select` and `expand` now reach the dispatcher as the array they are. One malformed input moves the other way, to match `@objectstack/plugin-hono-server`: a query pair with an EMPTY name (`?=x`) is no longer handed to the dispatcher, so a door with a closed parameter set reads it as absent where it used to refuse it as an unknown parameter `""`. The `@objectstack/client` SDK sends each single-valued name once, and the Console's package delete sends `keepData` once. +- e1cefa5: fix(hono): `createHonoApp` answers an escaped throw that declares an ADR-0112 envelope as that envelope, not as `500 INTERNAL_ERROR` (#23365) + + `@objectstack/hono`'s `createHonoApp` has two catch arms: the auth mount's, and + the `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. Both answered + an escaped throw from its `statusCode` alone, so a producer that declared + `status` and a registered `code` lost both on this host. A `GET`, `PATCH` or + `DELETE` of `/data/:object/:id` for an id that names no row answered + `500 INTERNAL_ERROR` where the standalone REST server answers + `404 RECORD_NOT_FOUND`. The `/data` version refusals answered `500` too (a stale + `If-Match`'s `409 CONCURRENT_UPDATE`, and `If-Match: ""`'s + `400 VALIDATION_FAILED`), and so did an authorization-store outage + (`AuthzStoreUnavailableError`, `503 SERVICE_UNAVAILABLE`). + + **What changed.** Both arms now answer a throw that carries **both** a declared + ADR-0112 status (a key of `HttpStatusErrorCodeMap`) **and** a code registered in + `ErrorCode` as that envelope. The recognition is `declaredEnvelopeForThrow`, the + rule `@objectstack/plugin-hono-server`'s direct-mount routes have used since + #16545, so the two Hono doors answer one throw alike by construction: the same + two conditions, the same 5xx disclosure filter (`looksLikeInternalErrorLeak`), + and `details` and `userMessage` forwarded only when the producer declared them. + + The rule's other limbs come with it, as they did on the direct-mount door. A + validation-shaped throw answers `400 VALIDATION_FAILED` with its `fields[]`. A + driver fault the engine has already classified keeps its `500` and now carries + the code it declared instead of `INTERNAL_ERROR`. Measured: `GET /data/:object` + over an object whose table is missing answers `500 DATABASE_ERROR`, with the + same message as before. + + **What did NOT change**, pinned in the same PR through a real `createHonoApp` + over the real dispatcher and protocol (`packages/qa/http-conformance`): + + - a throw that is **not** such an envelope answers what it answered before. A + bare `Error` is still `500 INTERNAL_ERROR` carrying `code` and `message`, with + no `cause` in the body, and a throw that declares `statusCode` with no + registered code keeps that status; + - ⛔ no error code is minted and no ledger row is added. + + `@objectstack/plugin-hono-server` gains one export, `declaredEnvelopeForThrow`, + which was module-private. `createHonoApp` imports it rather than restating the + rule. Its behaviour is unchanged. + + Earlier entries say a `createHonoApp` host still renders the `/data` version + refusals as `500`. This change ends that: they are answered `409` and `400` as + declared. + + The `409 CONCURRENT_UPDATE` this host now answers carries `currentVersion` and + `currentRecord` in `error.details`, the two fields the standalone REST server's + `409` carries; `currentRecord` is the row as the caller reads it. + + Clause-②: yes (widening) + +### Patch Changes + +- 19c554d: fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentVersion`, as the standalone REST server's does (#23531) + + A stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `409 CONCURRENT_UPDATE`. The standalone REST server's answer carries the server's `currentVersion`, which `wire-format.mdx` documents and a conflict dialog needs: it is the token "Overwrite anyway" sends back as the next `If-Match`. The two Hono doors answered the same refusal without it. They are `@objectstack/hono`'s `createHonoApp`, which answered it `500 INTERNAL_ERROR` (the `@objectstack/hono` entry for #23365 makes it a `409`), and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, which answered it with `code` and `message` only. + + - FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` + - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` + + The field rides `error.details`, the wrapped envelope's declared context slot. `@objectstack/client` reads a wrapped body's context from there, so `err.details.currentVersion` now carries it on these hosts too. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the `409` the `@objectstack/hono` entry for #23365 states. No other answer changes. + + `currentRecord` rides beside it, as on the REST server: the row as the caller reads it, which the protocol re-reads under the caller's own context on a version mismatch (#23586). + + Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. + + Clause-②: no +- 5bb9a83: fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531) + + The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried neither field: the route answered it with `code` and `message` only, and `createHonoApp` answered it `500 INTERNAL_ERROR`, which the `@objectstack/hono` entry for #23365 makes a `409`. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it beside the server's `currentVersion`. + + - FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` + - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` + + `currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the `409` the `@objectstack/hono` entry for #23365 states. No other answer changes. + + Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. + + Clause-②: no +- fa6a608: fix(hono): `createHonoApp`'s catch arms withhold a driver diagnostic from an undeclared `500` instead of shipping it to the client (#23532) + + Clause-②: no + + `@objectstack/hono`'s `createHonoApp` has two catch arms: the auth mount's, and the `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. A throw that does not declare an ADR-0112 envelope is answered from its own `message`, and that message was shipped unread. A driver diagnostic therefore reached the client verbatim: on a host whose object `ghost` was registered but whose table was never synced, `POST /api/v1/data/ghost` answered `500 INTERNAL_ERROR` with the message `insert into [statement and bound values redacted] - no such table: ghost`, naming the SQLite error and the physical table. + + **What changed.** Before it ships that message, the arm now asks `looksLikeInternalErrorLeak` (`@objectstack/types`), as every other door that emits a thrown message already does: `HttpDispatcher.error`, the REST registrars, `@objectstack/plugin-hono-server`'s direct-mount routes and this adapter's own `/auth/config` arm. When the answer is a `5xx` and the message looks like a driver or SQL dump, the message is replaced by the same text those doors use, `Internal server error`. The status and the `code` are unchanged. + + **What did NOT change:** + + - a `5xx` whose message does not look like a dump keeps it: a bare `new Error('boom')` is still `500 INTERNAL_ERROR` with the message `boom`; + - a `4xx` keeps its message, as on the other doors, because a `4xx` message is an answer meant for the caller; + - a throw that declares an ADR-0112 envelope is answered as declared, as before. +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [e1cefa5] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [dd29bbe] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [70d4c06] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [c263588] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [cd86e41] +- Updated dependencies [395ca21] +- Updated dependencies [9e0945c] +- Updated dependencies [6ee483c] + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/plugin-hono-server@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index c0ace21b988..9e6dbaae58d 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md index 8b11c9f141c..5a680af1a8a 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/account +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 316080107e6..f457ae3ed1c 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md index a09cd72b3dc..22943d44a81 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/setup +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index b16d6c7b067..7ed6fc81ba0 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md index aca1e426633..d082aad65c2 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/studio +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 467dca48757..b62f93246d3 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index f36016d29ba..833880444ba 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,638 @@ # @objectstack/cli +## 18.0.0-next.2 + +### Minor Changes + +- 869a381: feat(cli): the organization-ownership ceremony carries the `sys_metadata` organization layer into the environment (ADR-0131 D10) + + Clause-②: yes (widening) + + The plan of `os migrate organization-ownership` gains a key, `metadataConflicts` on the `sys_metadata` table. It lists every overlay key that more than one row would hold once the organization leaves the key, with each row's id. `--apply` gains a flag, `--metadata-choice ROW_ID`. Nothing is removed from the plan or the apply. + + `--apply` shipped in 18.0.0-next.1. This adds to it, and refuses no invocation that release could finish: a conflict now blocks the new step until it is chosen, but on such a database 18.0.0-next.1's `--apply` already stopped at `column-drops`, which never drops `sys_metadata`'s still-declared column. + + `--apply` gains a step, `metadata-promotion`, after the authored catalog conversion: + + - **Form withdrawals are carried first, under every posture, and fail-closed.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves it through the metadata door, into the view's own package, and any organization's withdrawal wins. + - **Under `single`, an organization customization whose key no other row holds becomes the environment's, in place.** This covers the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`; the key is type, name, package and bundle member. The row takes its history with it. + - **A key held by more than one row is a conflict, listed in the plan.** Under `single` it blocks the step until the operator chooses one row with `--metadata-choice ROW_ID`, once per conflict. + - The key ends with exactly one environment row. Where the key has an environment row, that row takes the chosen body in place, and its version names the chosen row's snapshot. Where it has none, the chosen organization row is promoted in place. A withdrawal the body keeps is also saved through the metadata door. + - The in-place write does to the row what the door does. It asks the door's lock verdict first, so a locked item stays locked and the conflict blocks. It keeps the row's stored `_lock` keys. It restamps `checksum`. + - Any public-form withdrawal another row of the key holds is kept on that body. + - The environment row is read back. Only then are the key's organization rows deleted, in one statement, by id. + - A rerun after an interruption accepts the same `--metadata-choice` and finishes. + - **Under a wall (`isolated`, `group`), nothing is promoted unless the operator names the row, and nothing is ever deleted.** A named row's body becomes the environment's as above. Every organization row left, the chosen one included where the key has an environment row, is named in the report as remaining and blocking `column-drops`, with the remedy. + + **A choice deletes only rows the reviewed plan listed.** `--metadata-choice` and `--template-choice` delete the other rows of their conflict. So the apply reads the conflict groups the plan file records (`metadataConflicts` and `templateConflicts`). It refuses, before anything is written, a choice whose conflict now holds other rows than the reviewed plan listed, with a new reason, `plan-conflict-mismatch`. The refusal names the conflict, the rows added and the rows gone. Plan again, review, and pass the new file. + + `event_seq` and every `(type, name, version)` of `sys_metadata_history` become unique environment-wide. The metadata door numbers a save from history in its own scope, so each lineage is placed where no environment save can land on it: + + - **A promoted row takes its history with it.** Before the row leaves its organization, that organization's lineage of the item is moved above the environment's numbers, and above every other number that organization holds, then becomes environment history. Every later environment save of the item numbers above it, and the row's version still names its own snapshot. + - **A new step, `history-renumber`, runs just before `column-drops`, on every apply, even after a checkpoint.** It lays out every lineage still tagged to an organization above the environment's: each organization's event range by one offset, its commit ranges with it, and each item's version lineage, the organization's rows with it. + - Under `single`, that history then becomes the environment's too. + - Under a wall it stays its organization's. + + `column-drops` runs only after that same apply's `history-renumber` has verified `event_seq` and every `(type, name, version)` unique environment-wide. A duplicate it cannot lay out is named, and blocks. + + Order within an organization, and the version a rollback resolves, are unchanged. Nothing in history is deleted. + + On a 17.x database `sys_metadata` has no `bundle_member` column. The ceremony boots with schema changes held, so it reads that column as absent, the overlay key's NULL member, and plans the table rather than refusing it. + + ```bash + os migrate organization-ownership --out plan.json # review plan.json: metadataConflicts lists every conflict + os migrate organization-ownership --apply --backup-taken --plan-file plan.json --metadata-choice ROW_ID # one per listed conflict + ``` +- f9b4217: feat(cli)!: `os migrate organization-ownership --apply` executes only the plan the operator reviewed (ADR-0131 D10) + + Clause-②: yes (widening) + + + + **BREAKING** (a narrowing of `--apply`'s accept set, released in 18.0.0-next.1), shipped as `minor` under the launch-window convention for breaking changes. It also widens the command: a new flag, `--plan-file`, new refusal reasons, and two new keys on the apply report. + + `--apply` now requires `--plan-file FILE`, the plan file a plan run wrote. Before it writes anything, it compares that file with the plan it computes, and refuses when any of these differs: + + - **The inventory digest** (`plan-digest-mismatch`). The fates it would run are not the ones the reviewed plan showed: a different `--fate-supplement`, or another release's inventory. Without this check, planning with supplement A and applying with supplement B ran B's fates unreviewed, including a mirror deletion A never listed. + - **The tenancy posture** (`plan-posture-mismatch`). The plan file records the posture it was computed under (`OS_TENANCY_POSTURE` / `OS_MULTI_ORG_ENABLED`), and the posture decides which rows attribution gives an owner and which it reports. A plan reviewed under a wall, which lists rows as reported, would otherwise give them owners when applied under `single`. + - **The database** (`plan-database-mismatch`). + + The refusal names both values and the remedy: run the plan again with the same flags (or run the apply under the plan's posture), review it, and pass the new file. No flag skips the comparison. + + A file that is not an organization-ownership plan, or that lacks its digest, posture or database, or cannot be read, is refused as `plan-file-unusable`. `--apply` without the flag is refused as `plan-file-required`. Both refusals come before the backup question and before the database is opened. + + A checkpoint recorded under another digest is no longer discarded silently. The reviewed plan supersedes it: every step runs again (each is idempotent), and the apply report names the discarded checkpoint in `restartedFrom`, beside `reviewedPlan`. + + ## FROM → TO + + | invocation | FROM (18.0.0-next.1) | TO | + |:--|:--|:--| + | `os migrate organization-ownership --apply --backup-taken` | performed the plan it computed at apply time | refused, `plan-file-required`: run `os migrate organization-ownership --out plan.json`, review it, then `--apply --backup-taken --plan-file plan.json` | + | `os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken` | performed the plan it computed with that supplement | refused, `plan-file-required`: plan with the same `--fate-supplement`, then add `--plan-file plan.json` | + | `--apply … --plan-file plan.json`, where the file was planned with another supplement, release, posture or database | (no such flag) | refused with `plan-digest-mismatch`, `plan-posture-mismatch` or `plan-database-mismatch`, naming both values | + + ```bash + os migrate organization-ownership --out plan.json # plan, then review plan.json + os migrate organization-ownership --apply --plan-file plan.json --backup-taken # perform exactly that plan + ``` +- 086fa46: feat(cli,plugin-dev): an app under `os serve` or `DevPlugin` that declares its own `sys_organization` default record page boots, and its page is the only default + + Clause-②: yes (widening) + + A deployment holds at most one `isDefault: true` record page per object, and a second one is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`. `plugin-auth` registers `sys_organization_detail` as the default record page of `sys_organization` unless it is constructed with `registerOrganizationRecordPage: false`. `os serve` and `DevPlugin` construct `AuthPlugin` themselves and never passed that option, so an app served by either one that shipped its own default page for `sys_organization` was refused at boot, with no switch to set. + + - **What changes.** Both roots now read the app's own pages before they construct `AuthPlugin`. When the app declares an `isDefault: true` record page for `sys_organization`, they pass `registerOrganizationRecordPage: false`, and the app's page is the only default. When it does not, `plugin-auth`'s page registers as before. There is no new configuration key: the page the author already wrote is the switch. Plugin order and precedence rules play no part. + - **One rule for both roots.** The reading is `stackDeclaresOrganizationRecordPage(stack, plugins?)`, a new export of `@objectstack/core`. It reads the stack's `pages` (or each package body's, for a multi-package stack) and the bundle of each app plugin already in `plugins`, and judges each page with `defaultRecordPageObject` from `@objectstack/spec/ui`, the reading the registration refusal uses. + - **The refusal's remedy (`@objectstack/objectql`).** `DEFAULT_RECORD_PAGE_CONFLICT`'s message now says to set `isDefault: false` on one of the two pages, "or compose the deployment without the other package's page". It said to compose the deployment without the other package, which a host cannot do when that package is the platform's auth plugin. The code, the `422` status and the named pages are unchanged. + - **Nothing to adopt.** An app that ships its own `sys_organization` default record page boots under `os serve` and `DevPlugin` with no further change. A deployment that composes its own kernel still sets `registerOrganizationRecordPage: false` on `AuthPlugin` itself. +- a1285f2: feat(cli): `os meta delete` and `os meta register` take `--package `, so a CLI user can follow the `409 RESOURCE_CONFLICT` remedy the metadata doors prescribe for an item two packages hold (#23350) + + Clause-②: yes (widening) + + **What is new.** Both commands declare an optional `--package` flag, a package id such as `com.acme.crm`, and forward it verbatim as the SDK's `packageId`, sent as `?package=`: + + ```bash + os meta delete view shared_grid --package com.acme.crm # removes com.acme.crm's row of the item, no other package's row + os meta register view --data view-def.json --package com.acme.crm # writes com.acme.crm's row of the item + ``` + + **Why.** A metadata item can hold one row per package (ADR-0048). When two or more packages hold an item, the reset door `DELETE /api/v1/meta/:type/:name` and the save door `PUT /api/v1/meta/:type/:name` both refuse a request that names no package, with `409 RESOURCE_CONFLICT`. The refusal names the packages and prescribes `?package=`. Neither command could send it, so a CLI user was told to do something the CLI could not do. + + **Unchanged.** Without `--package`, both commands send the same request as before: `os meta delete` adds no query string unless `--draft` is given, and `os meta register` saves active with no query string. `--package` combines with `--draft` and `--if-match` on `os meta delete`. An empty `--package` (for example `--package "$PKG"` with `PKG` unset) is refused before anything is sent, as an empty `--if-match` already is, because a package-less request is not always refused: the server may resolve it to a row the operator did not mean. + + **`@objectstack/client` (patch).** The `DeleteMetaItemOptions` docblock now names the reset door's carriers (`If-Match`, `state`, `package`, `dropStorage`, `member`) instead of stating a count that had gone stale. No type or behavior changes. +- 0b9391b: fix(cli)!: `os migrate organization-ownership --apply` refuses to run beside a process holding the SQLite database open, as its sibling migrate commands do, and the plan warns + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM.** `os migrate organization-ownership --apply` opened a SQLite database that another process held open (a running `os dev` or `os serve`, or a runtime's pooled connection) without checking for it, and ran the ceremony beside that process. `os migrate apply` and the other data migrations probe the database first; this command did not. One measured run of the plan and then `--apply` beside a runtime holding a connection left that runtime writing to a deleted `-wal` file, and a row it wrote afterwards never reached the database. + + **TO.** Before it boots the stack, the command probes the database the way its siblings do. Beside a holder, `--apply` is refused with exit 1: the ceremony boots nothing and writes nothing (the probe itself opens the file only briefly, to check). The check runs first, so a busy `--apply` that also lacks its plan file or its backup acknowledgement answers reason `database-busy` rather than `plan-file-required` or `backup-unacknowledged`; it is refused either way. The text output names the file and, when the probe can tell, the process holding it, then the remedy: stop the process using it and re-run. With `--json` the refusal uses this command's own envelope: `{ "error": "apply_refused", "reason": "database-busy", "database", "signal", "detail", "hint" }`. There is no `--force`: the ceremony has no flag that skips its reviewed plan or its backup either, and its refusal never names one. The plan (no `--apply`) writes no row or table, so it still runs. Its text output warns that `--apply` will refuse until the database is free, and its `--json` document is unchanged. + + **Unchanged.** A PostgreSQL, MySQL or remote libSQL target is not probed. A probe that cannot tell (for example, the database file cannot be read) warns in the text output and proceeds; under `--json` it is silent. The probe reads the project's `.env` files, as the run itself does. + + **Remedy.** Stop the process that holds the database file, then re-run `--apply`. +- dd29bbe: feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`. + + FROM → TO: + + - A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply --plan-file FILE` with that plan file, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. + - `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table (the D7 tables and the `sys_metadata` family among them) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified. + - There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check. + + What does not change: + + - A fresh database boots: no table it creates carries a retired column, so no marker is needed. + - A database whose ceremony verified boots, whatever its columns. + - The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an `organization_id` column is refused, as above. + - A driver with no physical columns to read (memory, MongoDB) is not checked. + - A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. +- 6f21216: feat(plugin-security,cli)!: `sys_user_permission_set.permission_set_id` is retired, the boot-time grant-name backfill is deleted, and a grant is unique per user, set and organization (ADR-0131 D4/D10) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + A permission-set grant names its set by `permission_set`, its one reference (ADR-0131 D4). The legacy id column is no longer declared, and the grant's uniqueness now keys the name. + + FROM → TO: + + - `sys_user_permission_set.permission_set_id` → `permission_set`, the machine name of the set the grant holds. A seed row or data-door write that still carries `permission_set_id` is refused with `400 INVALID_FIELD`. Fix: delete the key and name the set, `{ user_id, permission_set: '', organization_id }`. + - The unique index (`user_id`, `permission_set_id`, `organization_id`) and the (`permission_set_id`) index → one unique index on (`user_id`, `permission_set`, `organization_id`), declared `unique: 'organization'`: its organization key part is NULL-safe (ADR-0120 D3), so it refuses a second grant of one set to one user in one organization AND a second platform-wide grant (organization NULL), for every writer and every write path — batches, multi-row updates, concurrent writes and raw SQL included. The data doors answer `409 UNIQUE_VIOLATION` (the engine throws `DuplicateRecordError`, `DUPLICATE_RECORD`). Fix: keep the grant that exists; to change it, update that row. + - The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → the v18 ceremony: plan it with `os migrate organization-ownership --out plan.json`, review the file it writes, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then. + + On a 17.x database every grant names nothing until `--apply` names it, so between the first v18 boot and `--apply` no grant confers anything, the platform administrator's included. + + What else changes: + + - The plan run, `os migrate organization-ownership --out plan.json`, reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL): + - `duplicates`: groups of named grants holding one set for one user in one organization. `--apply`'s `column-drops` step does not drop `permission_set_id` while one group remains. + - `rewriteCollisions`: legacy grants whose derived name their user already holds in the same organization. `--apply`'s rewrite names none of them and blocks, naming both ids. + - On a database without the `permission_set` column it counts zero and names the remedy (boot this release once), instead of refusing the plan. + + The ceremony deletes no grant: remove the one you do not keep, then re-run. + - The organization-administrator reconcile and its boot sweep write no grant for a pair holding an unnamed grant in that organization: the ceremony's rewrite names that grant, and a named grant beside it would hold the key the rename needs. + - `--apply` drops the two retired indexes before the column, in a separate earlier schema-drift apply: MySQL refuses to drop a column a key still uses. + - The organization-administrator reconcile and its boot sweep, the platform-admin bootstrap's unnamed-grant leg (Leg C) and the catalog reference report read grants by name only. A legacy grant that names nothing confers nothing, still withholds the platform-admin promotion while it is unscoped, and is reached by no revocation until the ceremony's rewrite names it; once named, the boot sweep revokes it where its pair no longer qualifies. The report's `UnnamedGrant` entries lose `permissionSetId`. + - `GET /meta/object/sys_user_permission_set` no longer serves the hidden `permission_set_id` field. +- 2811a63: feat(cli): `os meta register` takes `--force`, so a CLI user can send the acknowledgement the `409 DESTRUCTIVE_CHANGE` refusal prescribes (#23480) + + Clause-②: yes (widening) + + **What is new.** `os meta register` declares a boolean `--force` flag, off by default and with no short alias. When given, it is forwarded as the SDK's `SaveMetaItemOptions.force`, sent as `?force=true`: + + ```bash + os meta register object --data object-def.json --force # applies a save that drops or transforms existing data + ``` + + **Why.** The save door `PUT /api/v1/meta/:type/:name` refuses an `object` save that removes a field or changes a field's type incompatibly, with `409 DESTRUCTIVE_CHANGE` and a message ending "re-submit with ?force=true to proceed." The command had no way to send that parameter, so a CLI user was told to do something the CLI could not do. + + **What a refusal now prints.** The server's message is passed through unchanged. On a run without `--force`, one more line names the flag: in the text output as a warning line under the error, and in the `--format json` envelope as a new `hint` key beside `error`, `code` and `httpStatus`. Every other failure is reported exactly as before, with no `hint`. + + **Unchanged.** Without `--force`, the command sends the same request as before: no `force` parameter at all, never `?force=false`. A non-destructive save with `--force` lands exactly as the same save without it. `--force` combines with `--package`. +- 6074eaf: fix(cli)!: `os migrate apply --allow-destructive` holds back the `organization_id` drop on every table the organization-ownership ceremony plans, not only the platform inventory's, until the ceremony has verified (ADR-0131 D7, D10 item 5) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **The contract this meets.** The drop gate's own header (`packages/cli/src/utils/organization-ownership-drop-gate.ts`) already states it: the drop of `organization_id` on a table the ceremony owns waits for the ceremony's verified marker, and "⛔ No flag stands it down". ADR-0131 D10 item 5 is the rule behind it: a v18 runtime refuses to boot against a database the ceremony has not completed, and offers no escape hatch that silently skips that check. A column dropped ahead of the ceremony is that escape hatch, because the boot refusal then finds no column to refuse over. + + **FROM.** `os migrate apply --allow-destructive` held back no `organization_id` drop. Every one the schema-drift report offered was applied with no ceremony marker on the database, among them those on the objects a host declares platform-global (ADR-0131 D7, the `org-scoping` service's `platformGlobalObjects`) and on any host or application object that declares no organization column of its own. Measured on a host composition that declares two objects platform-global: `os migrate apply --database-url file:v17.db --allow-destructive --yes --json` exited 0 and applied `drop_column organization_id` on both. + + **TO.** The gate holds back every `organization_id` drop the drift offers. The ceremony's plan meets every physical table that carries the column, with an inventory row, a `--fate-supplement` row, the application-object fate, or a refusal by name; the drift offers only a column that is physically there. So this is the ceremony's own table set, read without a copy of its inventory and without the fate-supplement file `os migrate apply` is never handed. It includes an application object that declares `systemFields: { tenant: false }` over a table that still carries the column: the ceremony plans that table, and the v18 boot refusal refuses over it too. While the `sys_migration` row `adr-0131-organization-ownership` is not verified, the run is refused with the existing refusal. It exits 1 and prints "Refusing this --allow-destructive run: it would drop organization_id from TABLES", naming `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN`, as the remedy. Under `--json` it answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, the `detail` and the `marker`. The refusal holds the whole `--allow-destructive` run: nothing in it is created, applied or dropped. + + **Unchanged.** Once the ceremony's marker is verified, the drop applies. A destructive change to any other column applies as before. A run without `--allow-destructive` is unchanged. There is no new flag, and no flag skips the refusal. + + **Remedy.** Run `os migrate organization-ownership` (with `--fate-supplement FILE` when the host provides objects of its own), review its plan, run `os migrate organization-ownership --apply --plan-file PLAN`, then re-run `os migrate apply --allow-destructive`. + +### Patch Changes + +- b9b6189: fix(cli): `os migrate organization-ownership --apply` names the uncomposed security plugin, and no longer prescribes removing the grants or bindings of the platform's own sets and positions, when it runs without an auth secret + + Clause-②: no + + **What was wrong.** The ceremony's boot composes the security plugin only where `os serve` would: behind the auth gate, which needs `OS_AUTH_SECRET` or a development boot. That plugin declares the platform's shipped permission sets (`admin_full_access`, `organization_admin_no_bypass`, `member_default` and the rest) and the built-in positions (`everyone`, `org_admin` and the rest). Run without the secret, the catalog resolves none of them, and the ceremony blocked with a remedy aimed at the rows instead of the cause: + + - `rewrite`, a grant that names a shipped set. FROM: "… which the security catalog does not resolve (…). Declare or author the set, or remove the grants, then re-run." Removing those grants strips the seeded administrator's standing. + - `rewrite`, a legacy grant whose set row is a shipped set (a 17.x database). FROM: "… still name no permission set (…). The rewrite could not derive their name (see its notes); name or remove each, then re-run." + - `authored-conversion`, the seeded `everyone` binding. FROM: "… the catalog holds no position of that name to carry them. Declare the position (or remove the bindings), then re-run." + - The rewrite gate under a wall, the same binding. FROM: "… deleting them would lose a grant. The authored-conversion step carries them; resolve its blocker, then re-run." Under a wall that step only reports, so it has no blocker to resolve. + + **What changed.** When the boot did not compose the security plugin and the name is one the plugin declares, each blocker keeps its first sentence and its remedy becomes, TO: the composition's own sentence, the one `os migrate security-catalog-overlays` prints ("Did not compose the security plugin: `os serve` composes none here (REASON), so its shipped permission sets are not package-held names on this deployment's boot."), then "NAME is one of the permission sets it ships: keep the grants, and re-run where the security plugin is composed." For a position it reads "NAME is one of the built-in positions it declares: keep the bindings, and re-run where the security plugin is composed." A legacy grant's blocker names its set row: "N grant(s) still name no permission set (…): their set row is NAME, which the security catalog does not resolve." + + **Unchanged.** + + - Every step still refuses. Nothing is resolved, skipped or guessed, and nothing is deleted. + - A name the plugin does not declare keeps its remedy. So does every name when the boot composed the plugin. Whether it was composed is the boot's own answer, never inferred from a missing name. + - A legacy grant left unnamed for any other reason (no id, a missing set row, another organization's row) keeps the general blocker. +- 3a2adc8: fix(cli): `os migrate meta --stored` no longer tells the operator that a row it could not rewrite is fine; a flow the engine refuses is named as not registered at boot + + Clause-②: no + + The closing line of a `--stored` run (human output, not `--json`) that reports `failed` rows said every one of them was fine. That is false for two of the pass's four failed kinds. A stored body that is not valid JSON does not load at all. A flow that does not canonicalize fails the same parse in `AutomationEngine.registerFlow`, so it is not registered at boot and does not run. + + FROM: "N row(s) could not be rewritten. They keep reading canonically through the chain, so nothing is broken — but their stored bytes stay legacy until the reason above is fixed." + + TO: "N row(s) could not be rewritten. Each stays as stored, and reads as it did before this run, until the reason printed beside it is fixed. That does not mean it works: a row whose stored body is not valid JSON does not load at all, and a flow whose reason says it does not canonicalize is refused by the engine at boot as well, so it is not registered and does not run." + + The flow sentence is printed only when a failed row is a flow. The report, its row reasons, the exit code (1) and the `--json` payload are unchanged. +- 0d784da: fix(cli,plugin-security): `os migrate organization-ownership --apply` resolves the built-in positions and the `everyone` baseline, so a 17.x database's seeded `everyone` binding no longer blocks the ceremony + + Clause-②: no + + - **What was wrong.** The ceremony's boot composes the security plugin for its declarations only: `init()` runs, and `start()` and every lifecycle hook do not. The plugin declared the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) in `start()`, and the `everyone` baseline at `kernel:ready`. So the ceremony's catalog held neither. Every 17.x environment database carries a seeded binding from `everyone` to `member_default`. On every such database, `authored-conversion` stopped with "Binding rows give position everyone the sets member_default, and the catalog holds no position of that name to carry them". With `OS_MULTI_ORG_ENABLED=true`, the rewrite gate stopped on the same binding ("deleting them would lose a grant"). + - **What changed.** `SecurityPlugin.init()` now declares the six built-in positions. It also declares `everyone` naming the deployment baseline: the app's `isDefault` set composed with `member_default`. These are registry declarations only, and nothing is read from or written to the database. The ceremony's catalog now resolves `everyone` with `member_default`, as a started kernel does, so that binding converts as declared. A binding that names a position no catalog declares still blocks. + - **The baseline in `init()` is the fail-closed reading.** The high-privilege check runs with no declared-capability context there, because the stack's declarations are still arriving while plugins initialize. So a shipped baseline set whose system permission only an app's capability declaration excuses is not named in `init()`. The `everyone` derivation at `kernel:ready` is unchanged. It runs the check against every declared capability, adds the package defaults a `manage_metadata` holder accepted, and replaces the `init()` declaration. + - **What a started kernel sees.** Its catalog after boot is the same. Three things move earlier or stay put: + - the six positions, and `everyone`'s baseline, are in the engine registry from the plugin's `init()` instead of from its `start()` and `kernel:ready`; + - if the `kernel:ready` derivation fails, `everyone` keeps the baseline from `init()`, where it used to name no set; + - the "the built-in positions … were NOT declared as position metadata" warning, for an engine that exposes no registry, now prints from `init()`. + - **Unchanged.** The cold boot's refusal of an environment-held name still skips the built-in names. A stored definition under a built-in name still shadows the declaration at read. The ceremony's `authored-conversion` and rewrite steps are unchanged. The ceremony still does not read the accepted package defaults: a binding to a set that only an acceptance puts on `everyone` still blocks. +- 4369677: fix(cli): `os migrate organization-ownership --apply` no longer blames the release for a column the ceremony's composition does not declare (ADR-0131 D10, column drops) + + Clause-②: no + + The ceremony drops `organization_id` only through the schema-drift ceremony, so only on an object its boot declares, and its boot declares the composition it is run in (the project directory and environment). Run in a composition that does not mount an object's provider, the step used to answer that table with "the object this runtime declares still carries the column … Run the ceremony with the release whose declaration drops it". That sentence was false: the object was not declared at all, and the same release drops the column wherever it is declared. Measured cases are `sys_flow_dispatch` without `requires: ['automation']`, `sys_presence` without `realtime`, and `sys_audit_log` without the auth family (no `OS_AUTH_SECRET`). + + Such a table is now answered `not-declared`: + + - the step still blocks and keeps the column; + - its blocker names the table and the remedy: re-run the ceremony where the deployment's composition is declared, from its project directory and environment (the `requires` capability whose provider declares the object, and `OS_AUTH_SECRET` for the objects the auth family declares); + - the `column-drops` step's summary counts these tables as `notDeclared` in the apply report (the report file and `--json`). + + The "not proposed" blocker is unchanged. It is now given only for an object the composition does declare. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- 252775d: fix(cli): `os meta delete` no longer prints "✓ Metadata deleted" when the reset door removed nothing (#23479) + + Clause-②: no + + **What changed.** The default (human) output of `os meta delete` now reads the reset door's `reset` flag, the same flag `--format json` and `--format yaml` already report as `deleted`. When the door answers `reset: false`, which means the delete's address held no row and nothing was removed, the command prints a warning line instead of the success line: + + | Run | FROM (before) | TO (now) | + |:---|:---|:---| + | `os meta delete view x`, nothing stored | `✓ Metadata deleted: view/x` | `⚠ Nothing deleted: view/x — the server found nothing to delete` | + | `os meta delete view x --package P`, P holds no row of it | `✓ Metadata deleted: view/x` | `⚠ Nothing deleted: package 'P' holds no row of view/x` | + | `os meta delete view x --draft`, no pending draft | `✓ Pending draft discarded: view/x` | `⚠ Nothing discarded: view/x has no pending draft` | + | `os meta delete view x --draft --package P`, P holds no pending draft of it | `✓ Pending draft discarded: view/x` | `⚠ Nothing discarded: package 'P' holds no pending draft of view/x` | + + **Unchanged.** When a row is removed (`reset: true`), the success line is the same as before, byte for byte. The exit code is `0` on both outcomes and on all three output formats, as before: a reset that removed nothing is a completed command, and the JSON and YAML outputs already report it as `success: true` with `deleted: false`. A script that needs to tell the two outcomes apart should read `deleted` from `--format json`, not the exit code. Failures (a refusal or a transport error) still exit `1`. +- 0f52f32: fix(cli): under a wall, `os migrate organization-ownership --apply` names the act that clears a position binding the rewrite gate refuses, instead of pointing at a step that reports done (#23542) + + Clause-②: no + + **What changed.** Under the `group` or `isolated` posture, no ceremony step carries `sys_position_permission_set` rows into a position definition. `authored-conversion` reports and finishes before it reads a binding (ADR-0131 D3). The rewrite gate still refused those rows, rightly, but its remedy sent the operator to `authored-conversion`, which had already reported `done` with no blocker. The gate still refuses, and nothing is deleted or guessed. Only the remedy changes, and it now depends on where the position's definition lives. + + The blocker's first sentence is unchanged: "Binding rows give position NAME sets its catalog definition does not name (SETS); deleting them would lose a grant." The sentence after it changes, under a wall only: + + - **A position no catalog declares.** + - FROM: "The authored-conversion step carries them; resolve its blocker, then re-run." + - TO: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). Declare the position NAME with permissionSets naming SETS, through one door only: the deployment's code (compiled into the artifact this ceremony boots), or the metadata door as the platform administrator (PUT /api/v1/meta/position/NAME). A cold boot refuses a position name a package declares and the environment catalog also holds. Then re-run." + - **A declared position the metadata door reports editable, whose definition lacks some of the bound sets.** + - FROM: the same pointer. + - TO: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). Add MISSING to the permissionSets of the position NAME through the metadata door as the platform administrator (PUT /api/v1/meta/position/NAME), then re-run." + - **A declared position the metadata door reports not editable.** A definition a package ships is sealed (ADR-0131 D6), so no door on the database adds the sets. The text states the fact and names no act. + - FROM: the same pointer. + - TO, when the definition's provenance names the package: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). Package PACKAGE ships the definition of NAME, which is sealed (ADR-0131 D6: the metadata door reports it not editable, lock LOCK), so no door on this database adds those sets." + - TO, otherwise: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). The metadata door reports the definition of NAME not editable (lock LOCK), so no door on this database adds those sets." + - When the door's verdict cannot be read, the text says so and names no act. + + Whether a definition is editable is the metadata door's own answer, the `editable` flag of its item read, the same verdict the ceremony already asks before its one in-place write. + + **Unchanged.** + + - Under `single`, the pointer at `authored-conversion` stays, because that step carries the bindings there. + - A built-in position that does not resolve because the boot did not compose the security plugin keeps that composition's remedy, under every posture. + - The `conflicting` blocker, for a name that organizations bind differently, keeps its text. It states the fact and names no act, because no door makes the rows agree. + - `authored-conversion`'s walled note is unchanged. The rewrite gate's blocker is where the operator reads about binding rows. +- c5dd7fc: feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. + + Migration: + + - `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it. + - A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`. + + `os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door. + + The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. + + `@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`. + + +- c2bb302: fix(cli): `os migrate organization-ownership` reads its SQL dialect once, through the driver resolver it shares with metadata-core, and refuses an unresolved dialect before any statement, naming the driver, with no SQLite default left + + Clause-②: yes (widening) + + **Who this is for.** An operator who runs the organization-ownership ceremony (plan or `--apply`) on a PostgreSQL or MySQL database whose driver's knex `config.client` is a client CLASS (knex's bring-your-own-dialect hatch) or a client string the ceremony does not recognise. + + **FROM.** Both arms took the dialect from `String(config.client)` alone, with a reader of their own. + + - Such a run was refused (`driver-unsupported`, exit 1) by the plan step, which runs first. A client class was stringified, so the refusal printed the class's source text in place of the driver's name. + - The apply step and its template lookup each carried a `'sqlite'` default for an unresolved dialect. Neither was reachable, only because the plan step had already refused. + + **TO.** The dialect is resolved once, from the stack's driver, before either arm runs a statement. The resolver is the one metadata-core's legacy-row reader uses: the driver's `dialectName` first, then a recognised `config.client` string. + + - Refused: a client class with no recognised `dialectName`, an unrecognised client string, `dialectName: 'unknown'` (what `SqlDriver` answers for either), and a driver with no dialect at all. Each gets `driver-unsupported`, exit 1, with no statement run and no plan file written. The refusal names the driver: its name, its `dialectName`, and its client string or client class name. + - No `'sqlite'` default remains. The plan and the apply take the resolved dialect and never re-derive it. + - The plan file's `client` field records the client only when it is a string. + + Measured on each in-repo SQL driver: `SqlDriver` on better-sqlite3, pg and mysql2, and `TursoDriver`, resolve to the dialect they did before. + + **metadata-core.** `dialectOfDriver(driver)` is exported. It is the one resolver behind `readLegacyOrganizationRows`, and answers: + + - `'sqlite' | 'postgres' | 'mysql'`; + - `'none'` for a driver that is not a SQL driver; + - `'unresolved'` otherwise. + + A `dialectName` string is the whole answer, recognised or not. A `config.client` is read only when there is no `dialectName`, and only as a string. +- 93f3526: fix(cli): the read-only `os migrate` arms say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying they write nothing + + **What was wrong.** These arms boot read-only: no DDL, no seed, SELECT statements only. But the boot still opens the database through the SQL driver, and the driver's connect switches a SQLite file to the journal mode it asks for: WAL, unless `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` is set in the command's own environment or the project's `.env` (then a WAL file is switched back to a rollback journal). On a file still on a rollback journal, a plan or a dry run therefore rewrote the file's header (the journal-mode bytes and the change counter) while saying "writes nothing" or "nothing in the database was changed". No row or table changed, and nothing was lost. + + **What changed.** Each sentence below now claims no row or table, and each warning is followed by one shared note: "Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only." + + - `os migrate organization-ownership` (the plan), beside a process holding the file. FROM: "The plan below writes nothing, but --apply will refuse until the database is free." This sentence has not been published: it arrives in this same release, with the occupancy gate the ceremony's `--apply` now takes. TO: "The plan below writes no row or table, but --apply will refuse until the database is free." and the note. + - Its closing line. FROM: "Plan written to FILE — nothing in the database was changed." TO: "Plan written to FILE — no row or table in the database was changed." On a SQLite target the note follows; on PostgreSQL or MySQL the line ends there. + - `os migrate plan`, beside a holder. FROM: "The plan below is still accurate — nothing is written — but "os migrate apply" will refuse until it is free (or you pass --force)." TO: "The plan below is still accurate — it writes no row or table — but …", the rest unchanged, and the note. + - The dry runs of `os migrate audit-metadata-bodies`, `comment-reactions`, `files-to-references` and `summary-nulls`, beside a holder. FROM: "The dry run below writes nothing, but its counts may shift while that process is running." TO: "The dry run below writes no row or table, but its counts may shift while that process is running." and the note. + - The dry run of `os migrate multi-value-columns`, beside a holder. FROM: "The dry run below writes nothing." TO: "The dry run below writes no row or table." and the note. + - The help description of `os migrate multi-value-columns` (`os migrate multi-value-columns --help`). FROM: "… Dry-run by default: prints the exact statements and the database they would run against, and writes nothing." TO: "… and writes no row or table." The help text carries no note. + - The preview of `os migrate meta --stored`, beside a holder. FROM: "The preview below writes nothing, but a live process may be saving metadata while it runs." TO: "The preview below writes no row or table, but a live process may be saving metadata while it runs." and the note. + + **Unchanged.** No exit code, `--json` document or refusal changes; the warnings are still printed in the text output only. What the driver does on connect is unchanged: once a file is in the mode the driver asks for, a read-only run leaves it byte-identical. +- edac218: fix(cli): a production `os serve` of a host config that resolves no storage says so before any plugin starts, and refuses to boot when its auth service needs a driver + + Clause-②: no + + A host config (one whose `plugins` hold plugin instances) served in production with no recognized `OS_DATABASE_URL`, no `--dev` and no plugin named like a driver declares no default datasource. Nothing said so. + + - **Before.** The boot went on and died when `AuthPlugin` started: `✗ Plugin com.objectstack.auth failed to start - rollback complete: [ObjectQL] No driver available for object 'sys_organization'`. Before that line, objectql logged `ERROR sys_metadata could NOT be read at boot`, whose fix read `check the datasource behind sys_metadata — connection, credentials, and that the table exists`, for a datasource the operator never declared. No line named `OS_DATABASE_URL` or `--dev`. `examples/app-showcase` under a bare `os serve` took this path. + - **After, when the auth service runs under the `single` tenancy posture (the default) and no plugin registered a driver by the end of plugin init.** The boot is refused before any plugin starts, exit 1, with `✖ This production serve has no storage: …` and three fixes: set `OS_DATABASE_URL` (for example `file:./data/app.db`, or a `postgres://` or `mysql://` URL), mount a storage driver in the stack's `plugins` (for example `new DriverPlugin(driver)` from `@objectstack/runtime`), or run with `--dev`. Neither of the two lines above prints. Every configuration refused here failed to boot before this change. + - **After, when no driver is registered and the auth service does not need one at start** (no auth composed, as under `--no-server --preset minimal`, or a walled tenancy posture): one `⚠ No storage: …` line names the same fixes, and the boot goes on as before. A plugin that registers a driver in its `start()`, such as a module's `onEnable`, still works. + - **When a plugin registers a driver during init**, under any name, nothing is printed and nothing changes. + + `os start`, a standalone (non-host) config, `--dev` and any recognized `OS_DATABASE_URL` take other paths and are unchanged. +- fdbcec0: fix(cli): `os migrate security-catalog-overlays`, `recorded-by` and `resume`, `os secret orphans` and `rewrap`, and `os storage orphans` say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying they write nothing + + **What was wrong.** Each of these commands boots through the SQL driver before it prints, and the driver's connect switches a SQLite file to the journal mode it asks for (WAL, unless `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` is set in the command's own environment or the project's `.env`). Measured on a file still on a rollback journal, every command's default mode moved the header's journal-mode bytes (18 and 19, from 1,1 to 2,2) and its change counter, and changed no row or table, while printing "writes nothing", "nothing was written" or "Nothing was changed". The same held for an `--apply`, `--run` or `--delete` declined at its prompt, whose plain boot can also create a table the database is missing. Nothing was lost. + + **What changed.** Each sentence below now claims no row or table (no row, after a declined prompt) and, where the command opened a SQLite file, is followed by the note the read-only `os migrate` arms already carry: "Opening a SQLite file can still switch its journal mode (to WAL by default), which changes its header only." On PostgreSQL or MySQL the sentence ends without it. + + - `os migrate security-catalog-overlays` (the preview), beside a process holding the file. FROM: "The preview below writes nothing." TO: "The preview below writes no row or table." and the note. + - `os migrate recorded-by` (the dry run). FROM: "Nothing was changed. Re-run with --apply to rewrite them to NULL." TO: "No row or table was changed. Re-run with --apply to rewrite them to NULL." Its `--apply` declined at the prompt. FROM: "Aborted — nothing changed." TO: "Aborted — no row was changed." + - `os migrate resume` (the list). FROM: `Nothing was changed. Re-run with --run to act on one.` TO: `No row or table was changed. Re-run with --run to act on one.` Its `--run` declined at the prompt. FROM: "Aborted — nothing changed." TO: "Aborted — no row was changed." + - `os secret orphans` (the report). FROM: "Report only — nothing was written or deleted." TO: "Report only — no row or table was written or deleted." The note sits before the elapsed time. Its `--delete` declined at the prompt. FROM: "Aborted — nothing was written or deleted." TO: "Aborted — no export was written and no row was deleted." Its help description. FROM: "… Report-only by default: it writes nothing and deletes nothing." TO: "… it writes no row or table and deletes nothing." Its `--delete` flag description. FROM: "(default is a report that writes nothing)" TO: "(default is a report that writes no row or table)". + - `os secret rewrap` (the dry run). FROM: "Dry run — nothing was written." TO: "Dry run — no row or table was written." The note sits before the elapsed time. Its `--apply` declined at the prompt. FROM: "Aborted — nothing was opened or written." TO: "Aborted — no row was opened or written." Its help description. FROM: "… A dry run by default: it writes nothing without --apply." TO: "… it writes no row or table without --apply." Its `--apply` flag description. FROM: "(default is a dry run that writes nothing)" TO: "(default is a dry run that writes no row or table)". + - `os storage orphans`, its help description. FROM: "… Writes nothing, tombstones nothing, deletes nothing." TO: "… Writes no row or table, tombstones nothing, deletes nothing." Help text carries no note. + + **Unchanged.** No exit code, `--json` document, flag, prompt or refusal changes; the note is printed in the text output only. What the driver does on connect is unchanged: once a file is in the mode the driver asks for, these runs leave it byte-identical. +- 2906cd8: fix(cli): `os serve`'s ready banner prints an `API:` or MCP address only when an HTTP server is composed, and otherwise says once that none is served + + Clause-②: no + + - **Before.** `os serve --no-server` printed `➜ API: http://localhost:PORT/` and `➜ MCP: http://localhost:PORT/api/v1/mcp` with its "connect an AI client" hint, though `--no-server` had started no HTTP server and nothing listened on that port. An operator, or an AI client configured from the banner, was sent to an address that refused the connection. + - **After, with no HTTP server composed.** The banner prints no `API:`, `Console:` or `MCP:` row, and no connect hint or paths-only note. It prints one line instead: `➜ No HTTP server: --no-server was given and no plugin composed one, so no API or MCP address is served`. The line names `--no-server` only when that flag is the reason; a boot that asked for a server but composed none says `no plugin composed one`. + - **After, with an HTTP server composed.** The address rows are unchanged. That includes a `--no-server` boot whose config mounts its own `HonoServerPlugin`, because the banner checks the booted kernel for a registered `http.server` service rather than reading the flag. + + The ready line (`✓ Server is ready` or `⚠ … DEGRADED`) and every row that names no address are unchanged. So are the `objectstack:listening` IPC message and the `runtime...json` state file. +- a4b87ef: fix(cli): `os migrate` finds the SQL driver on a SQLite database served by `SqliteWasmDriver` (`wasm-sqlite://`, `*.wasm.db`, and the dev boot's step-down from better-sqlite3), so the organization-ownership ceremony and every `os migrate` command run on it instead of answering that no SQL driver is active + + Clause-②: no + + **Who this is for.** An operator whose database is served by `SqliteWasmDriver`, and in particular one who must run `os migrate organization-ownership` to bring a 17.x database through 18's boot refusal (ADR-0131 D10). + + **FROM.** The schema boot behind every `os migrate` command looked its SQL driver up by a list of kernel service names that did not include the one `SqliteWasmDriver` is published under (`driver.com.objectstack.driver.sqlite-wasm`). On such a database: + + - `os migrate organization-ownership` refused both the plan and `--apply` with `driver-unsupported` ("no SQL driver with a raw-SQL seam is active"), exit 1, while its own text said the ceremony supports SQLite. + - `os migrate plan` answered `{"error":"no_sql_driver","changes":[]}` and `os migrate apply` answered `{"error":"no_sql_driver"}`, both exit 0, and `apply` created nothing. + - `os migrate multi-value-columns` and `os migrate unmapped-columns` answered `no_sql_driver`, and the other commands in the family named the database `unknown`. + + **TO.** The lookup lists that key, so these commands find the driver and run on the database. The ceremony resolves its dialect to `sqlite` through the driver's `dialectName`. A driver without the schema-migration seam is still not found under any key, and a driver under a key the list does not name is still not found: the ceremony refuses both with `driver-unsupported`, as before. + + `os migrate plan`'s unmanaged-table section on such a database reads the table catalog. It now resolves the dialect from the driver's `dialectName` first; reading `config.client` as a string only, it had reported the section unreadable for this driver, whose client is a knex client class. + + An `os serve` boot pinned to an artifact by `OS_ARTIFACT_URL` uses the same lookup for its boot-time migration gate. On such a database that gate used to find no driver and let the boot go ahead with the artifact's schema changes unapplied, a destructive one included. It now runs as on every other SQL database: safe changes are applied at boot, and a destructive change refuses the boot and names the change and `os migrate apply --allow-destructive`. +- eea7649: fix(cli): `os migrate organization-ownership --apply` drops `organization_id` from an object the host declares platform-global when its fate is `column-drop`, for a host that brings its own engine plugin (ADR-0131 D7, D10 fate 1) + + Clause-②: no + + ADR-0131 D7: an object a deployment declares platform-global (the `org-scoping` service's `platformGlobalObjects`) gets no organization column on that deployment. The engine installs that declaration at the top of `ObjectQLPlugin.start()`, before it hands any object to its driver. + + **FROM.** A host config that brings its own `ObjectQLPlugin`, under the framework's plugin name, displaces the CLI's engine, and the migration boot composes host plugins for their declarations only, so that engine's `start()` does not run. The boot's own binding pass (the one that hands every declared object to its driver in place of that `start()`) did not install the declaration. Every object the host declared platform-global was planned with its organization column. The schema-drift ceremony therefore proposed no drop, and the ceremony's column-drops step blocked on a `column-drop` fate for such an object with "was not proposed for dropping by the schema-drift ceremony". `os migrate plan` showed no drift for the column either. + + **TO.** The binding pass installs the declaration first, as `start()` does, through the same reader (`readDeploymentOrgScopingEntitlement`, `@objectstack/core`) and the registry's own re-plan. A declared object is planned without the column, the drift proposes its drop, and the column-drops step drops it through the schema-drift ceremony. `os migrate plan` lists the column as an unmapped, destructive column, as a served boot of the same stack plans it, and the plan's notes name the objects re-planned. + + **Unchanged.** A host whose engine is the CLI's own already had the declaration installed by that engine's `start()`, and installing it again re-plans nothing. An object the host does not declare platform-global keeps its column, and a `column-drop` fate on it still blocks with `not-proposed`. A declared object whose column still holds a category's rows still blocks before any drop. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [c911ab6] +- Updated dependencies [87d0619] +- Updated dependencies [fba3712] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [b60eb78] +- Updated dependencies [584e737] +- Updated dependencies [54af6c0] +- Updated dependencies [be16feb] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [94924e0] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [05e3b8e] +- Updated dependencies [086fa46] +- Updated dependencies [c245986] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [fc1a55a] +- Updated dependencies [a1285f2] +- Updated dependencies [b1e9280] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [e1cefa5] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [73c7dc4] +- Updated dependencies [3b28905] +- Updated dependencies [1baf976] +- Updated dependencies [222293f] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [7cb33cf] +- Updated dependencies [d1e2841] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [b1cfa3d] +- Updated dependencies [56147a8] +- Updated dependencies [23e689d] +- Updated dependencies [a869ea6] +- Updated dependencies [7b64a3d] +- Updated dependencies [0fb55fc] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [30b0fc2] +- Updated dependencies [a6d0715] +- Updated dependencies [c2bb302] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [9f81260] +- Updated dependencies [e611e2b] +- Updated dependencies [b513343] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [c7dd48a] +- Updated dependencies [30c33e3] +- Updated dependencies [7e6e4e1] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [361a1c4] +- Updated dependencies [3bc407d] +- Updated dependencies [69351e9] +- Updated dependencies [f77ba41] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [9bb9370] +- Updated dependencies [53646fc] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] +- Updated dependencies [53e3638] +- Updated dependencies [140238a] + - @objectstack/plugin-security@18.0.0-next.2 + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-protocol@18.0.0-next.2 + - @objectstack/lint@18.0.0-next.2 + - @objectstack/rest@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/service-mailbox@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/verify@18.0.0-next.2 + - @objectstack/plugin-audit@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + - @objectstack/plugin-sharing@18.0.0-next.2 + - @objectstack/service-datasource@18.0.0-next.2 + - @objectstack/service-settings@18.0.0-next.2 + - @objectstack/service-storage@18.0.0-next.2 + - @objectstack/plugin-email@18.0.0-next.2 + - @objectstack/client@18.0.0-next.2 + - @objectstack/plugin-hono-server@18.0.0-next.2 + - @objectstack/mcp@18.0.0-next.2 + - @objectstack/cloud-connection@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/service-automation@18.0.0-next.2 + - @objectstack/driver-sql@18.0.0-next.2 + - @objectstack/driver-turso@18.0.0-next.2 + - @objectstack/driver-mongodb@18.0.0-next.2 + - @objectstack/plugin-approvals@18.0.0-next.2 + - @objectstack/driver-sqlite-wasm@18.0.0-next.2 + - @objectstack/driver-memory@18.0.0-next.2 + - @objectstack/console@18.0.0-next.2 + - @objectstack/account@18.0.0-next.2 + - @objectstack/setup@18.0.0-next.2 + - @objectstack/plugin-webhooks@18.0.0-next.2 + - @objectstack/service-job@18.0.0-next.2 + - @objectstack/service-messaging@18.0.0-next.2 + - @objectstack/service-queue@18.0.0-next.2 + - @objectstack/service-realtime@18.0.0-next.2 + - create-objectstack@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + - @objectstack/service-analytics@18.0.0-next.2 + - @objectstack/service-cache@18.0.0-next.2 + - @objectstack/service-package@18.0.0-next.2 + - @objectstack/service-sms@18.0.0-next.2 + - @objectstack/trigger-api@18.0.0-next.2 + - @objectstack/trigger-record-change@18.0.0-next.2 + - @objectstack/trigger-schedule@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + - @objectstack/plugin-pinyin-search@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index b013869d97a..9e6da68a536 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "description": "Command Line Interface for ObjectStack Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md index cfa98cf4743..cc0262e0fbd 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/client-react +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [a1285f2] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [361a1c4] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/client@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 9f83e9c87b5..aa9e74a3eae 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "React hooks for ObjectStack Client SDK", "main": "dist/index.js", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index d9dd44891f4..3d8446da41a 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,91 @@ # @objectstack/client +## 18.0.0-next.2 + +### Minor Changes + +- 361a1c4: feat(client): `meta.publishItem` can name the package whose pending draft it promotes (`packageId`, sent as `?package=`), so a caller can follow the `?package=` remedy the per-item publish door prescribes when it refuses a publish that names no package (#23658) + + Clause-②: yes (widening) + + **What is new.** `meta.publishItem`'s options gain `packageId?: string`, sent as `?package=` on `POST /api/v1/meta/:type/:name/publish`. The name, the query key and the omission rule are the ones `saveItem`'s and `deleteItem`'s `packageId` already use: only a non-empty id reaches the wire, and `undefined` and `''` send nothing. + + **Why.** A metadata item can hold one row per package (ADR-0048). A per-item publish that names no package promotes the draft a draft save naming no package writes, and the door refuses it in two states, each with `?package=` as the remedy and nothing published: `409 RESOURCE_CONFLICT` when two or more packages hold the item, and `404 NO_DRAFT` when the item's one package holds no draft and another package's draft is stored. This client had no option for it, so an SDK caller (an agent, the CLI, an app) could not do what the refusal said. + + FROM → TO: + + ```ts + // FROM: refused 409 RESOURCE_CONFLICT when two packages hold the item, with no way to name one + await client.meta.publishItem('view', 'shared_grid'); + + // TO: promotes com.acme.crm's pending draft into com.acme.crm's active row, and no other package's draft + await client.meta.publishItem('view', 'shared_grid', { packageId: 'com.acme.crm' }); + ``` + + **Unchanged.** A publish without `packageId` sends the same request as before: no query string, and the body `{ message }` when a message is given, `{}` otherwise. `packageId` combines with `message`, which still rides the body. The environment-scoped `client.environment(id).meta` declares no `publishItem`, and this release adds none. + +### Patch Changes + +- a1285f2: feat(cli): `os meta delete` and `os meta register` take `--package `, so a CLI user can follow the `409 RESOURCE_CONFLICT` remedy the metadata doors prescribe for an item two packages hold (#23350) + + Clause-②: yes (widening) + + **What is new.** Both commands declare an optional `--package` flag, a package id such as `com.acme.crm`, and forward it verbatim as the SDK's `packageId`, sent as `?package=`: + + ```bash + os meta delete view shared_grid --package com.acme.crm # removes com.acme.crm's row of the item, no other package's row + os meta register view --data view-def.json --package com.acme.crm # writes com.acme.crm's row of the item + ``` + + **Why.** A metadata item can hold one row per package (ADR-0048). When two or more packages hold an item, the reset door `DELETE /api/v1/meta/:type/:name` and the save door `PUT /api/v1/meta/:type/:name` both refuse a request that names no package, with `409 RESOURCE_CONFLICT`. The refusal names the packages and prescribes `?package=`. Neither command could send it, so a CLI user was told to do something the CLI could not do. + + **Unchanged.** Without `--package`, both commands send the same request as before: `os meta delete` adds no query string unless `--draft` is given, and `os meta register` saves active with no query string. `--package` combines with `--draft` and `--if-match` on `os meta delete`. An empty `--package` (for example `--package "$PKG"` with `PKG` unset) is refused before anything is sent, as an empty `--if-match` already is, because a package-less request is not always refused: the server may resolve it to a row the operator did not mean. + + **`@objectstack/client` (patch).** The `DeleteMetaItemOptions` docblock now names the reset door's carriers (`If-Match`, `state`, `package`, `dropStorage`, `member`) instead of stating a count that had gone stale. No type or behavior changes. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 1071abde810..1af6b1f4c14 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Official Client SDK for ObjectStack Protocol", "main": "dist/index.js", diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md index 165ffc8cb32..0f9a1e5b63d 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,143 @@ # @objectstack/cloud-connection +## 18.0.0-next.2 + +### Minor Changes + +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- daafae6: fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call + + Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal. + + + + **BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not. + + - **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there. + - **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart. + - **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change. + - **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`. + - **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire. + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both. + - **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered). + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [3b28905] +- Updated dependencies [1baf976] +- Updated dependencies [222293f] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [7cb33cf] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [56147a8] +- Updated dependencies [23e689d] +- Updated dependencies [a869ea6] +- Updated dependencies [7b64a3d] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [c2bb302] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [9bb9370] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-protocol@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index de28d859917..5e60c387171 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (cloud ADR-0008): the hub service, plan policy, and entitlements stay server-side.", "type": "module", diff --git a/packages/connectors/connector-imap/CHANGELOG.md b/packages/connectors/connector-imap/CHANGELOG.md index 0f2a4fafb69..ef80cea399c 100644 --- a/packages/connectors/connector-imap/CHANGELOG.md +++ b/packages/connectors/connector-imap/CHANGELOG.md @@ -1,5 +1,9 @@ # @objectstack/connector-imap +## 18.0.0-next.2 + +No changes in this release. + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/connectors/connector-imap/package.json b/packages/connectors/connector-imap/package.json index 0be21ebdaf7..49cca88c2b4 100644 --- a/packages/connectors/connector-imap/package.json +++ b/packages/connectors/connector-imap/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-imap", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "IMAP connector for ObjectStack — a read-only IMAP4rev1 session (EXAMINE, UID FETCH with BODY.PEEK) and the per-user-shaped mail account and credential abstraction the mailbox sync connects through.", "main": "dist/index.js", diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md index 08aa1af74dd..30d260eca09 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/connector-mcp +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 42aaa2d1419..56c633f75d3 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).", "main": "dist/index.js", diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md index 0aad5473f93..42300e9cce7 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/connector-openapi +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index b22cf66f627..ab844d81d7c 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 29404e73095..cbe506eb7f5 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/connector-rest +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index c640a870293..c4b107ce27b 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", "main": "dist/index.js", diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md index 2d1dbc62355..d0b216efd22 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/connector-slack +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index de4246be87f..49ca8168644 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).", "main": "dist/index.js", diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index 833f4e6ffe5..61df10ee8e1 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,172 @@ # @objectstack/console +## 18.0.0-next.2 + +### Major Changes + +- 53e3638: Console (objectui) refreshed to `1f23d5b070fa`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 76 releasing of 80 changesets added across 83 non-merge commits; omitted: 4 release-nothing changesets, 3 commits carrying no changeset (they ship no package code). + + - **major** — **BREAKING** — objectui moves to major 18, following `@objectstack/*` from 17 to 18 (objectui#12334). objectui's major is not a count of its own breaking changes: it tracks the major of the `@ob… (objectui `6f3299fe7`) + - **minor** — The bell's Activity tab and the console home's activity card now read business activity only: the shared activity feed filters `sys_activity` by `object_name` to the objects the u… (objectui `1f23d5b07`) + - **minor** — The approval decision panel (`record:approval_decision`) shows what was submitted (objectui#12483): the request's declared amount and a summary of its record snapshot. The amount… (objectui `521817ce9`) + - **minor** — Studio's Access pillar shows an open permission set as an item header and five tabs (objectui#12456, epic objectui#12446 decision C). (objectui `78e9fb2e9`) + - **minor** — Console top bar inside an app: from the `lg` breakpoint up, the search trigger is the bar's middle zone, centered between the left group and the utility cluster (objectui#12472). (objectui `1c3b0699a`) + - **minor** — The approval decision panel (`record:approval_decision`) names who a pending request waits on (objectui#12482, folding objectui#12077). Each pending approver is one chip under "Wa… (objectui `bcf4df0ff`) + - **minor** — The Studio's Automations pillar takes the redesign's layout (objectui#12454, epic objectui#12446). (objectui `8146afe88`) + - **minor** — The console list page's header no longer draws a copy of an action that the selection bar already carries (objectui#12462). (objectui `d35cfbf3b`) + - **minor** — The console list page draws its saved-view tabs and the list's Filter, Sort, Display and Search controls on one row (objectui#12440). (objectui `d4bc81eab`) + - **minor** — **BREAKING** — The page kind `jsx` is retired from the page node, following `@objectstack/spec` 18 (objectstack-ai/objectstack#23638), which refuses it at parse and names `html` as its replaceme… (objectui `21f957744`) + - **minor** — Studio Data pillar layout (objectui#12450, epic objectui#12446). (objectui `7a5f4c19a`) + - **minor** — Console list page: the table scrolls inside the page on desktop, and a right-edge shadow says more columns follow (objectui#12463). (objectui `c950345ab`) + - **minor** — A list's sort, column layout, Filter panel and tab order are now each user's own, and follow them to another browser (objectui#12418). (objectui `caac3b24e`) + - **minor** — **BREAKING** — Console top bar inside an app: the app pill and its PREVIEW chip are the left group, an object route draws no breadcrumb, and 「Edit app」 is an icon button at every width (objectui… (objectui `d9d9d9868`) + - **minor** — Studio design surface: Access is the fourth segment of the pillar nav, which now reads Data · Automations · Interfaces · Access (objectui#12452, epic objectui#12446 decision A). T… (objectui `d25585cff`) + - **minor** — The record header's favorite star draws only where the host wires a toggle, and the console record page no longer draws one (objectui#12427). (objectui `5c4deb56a`) + - **minor** — The console shell's sidebar trigger now lives in the app bar's left slot at every width (objectui#12429). Inside an app it is the bar's leftmost control: on a phone it opens the n… (objectui `e869e4127`) + - **minor** — Studio design surface: one layout skeleton for the four pillars (objectui#12459, epic objectui#12446). The shell header is a 48px three-column grid — the package drawn as an app c… (objectui `cf430cd10`) + - **minor** — Console sidebar: one Favorites section at the top, and one ⋯ menu on every entry (objectui#12427). (objectui `a8e5b7364`) + - **minor** — The console list page spends less of the screen on chrome (objectui#12423). (objectui `a2225f913`) + - **minor** — `MetadataDraftHeader`, the row type `MetadataClient.listDrafts()` declares, now declares `member` (objectui#12413). (objectui `431cece41`) + - **minor** — **BREAKING** — `createObjectStackUploadAdapter`'s `scope` option is typed from the spec's upload-scope vocabulary (objectui#12055). (objectui `24804dcc6`) + - **minor** — "Publish all" publishes each language of a package-less email template by its own bundle member (objectui#12402, the publish half of objectui#12271). (objectui `919d5cd3a`) + - **minor** — `record:related_list` reads its parent record from `parentDataSource` (objectui#12294, ruling B on objectui#12296). The key is a member of the spec's `record:related_list` row and… (objectui `d81e46043`) + - **minor** — Studio's Automations rail and the open flow's Status pill read the automation engine's registration verdict, which the flow list now serves beside its items (`registrations`, `Flo… (objectui `dbc8c1169`) + - **minor** — Studio's flow designer and Debug run stop offering and emulating the single-brace `{var}` dialect, which `@objectstack/spec` 18 deletes (objectui#12329, ADR-0032 Decision 3). (objectui `52a5d81f8`) + - **minor** — **Clause-②: yes (an export widens, and an accept set narrows)** (objectui `96fcf305a`) + - **minor** — `RecordDetailsComponentProps.sections[]` declares `hideEmpty` (objectui#12348). (objectui `1c72d7768`) + - **minor** — Depend on `@objectstack/*` `18.0.0-next.1` (objectui#12370). (objectui `ff9df8840`) + - **minor** — `objectui validate` accepts `record:approvals` and `record:attachments` (objectui#12363). `@objectstack/spec` 18 declares both page blocks, `@object-ui/app-shell` renders both, an… (objectui `2b4d3f133`) + - **minor** — Studio stops inserting or prescribing the single-brace `{var}` token in the rest of the flow value slots `@objectstack/spec` 18 refuses it in (objectui#12372, ADR-0032 Decisions 2… (objectui `d78e70b87`) + - **minor** — The screen-flow runner reads the three `flows` translation keys `@objectstack/spec` added for a flow's option labels and its own toasts (objectui#12075, the console half of object… (objectui `42d8e8db6`) + - **minor** — Studio stops writing the single-brace `{var}` token in the flow fields `@objectstack/spec` 18 refuses it in (objectui#12101, ADR-0032 Decisions 2 and 3). (objectui `0a19a683f`) + - **minor** — **Studio no longer offers `externalSharingModel`** (objectui#12353). `@objectstack/spec` retires the key on the v18 line (objectstack#23268, ruling A): it has no runtime reader, a… (objectui `2d8450e46`) + - **minor** — The page block `record:approval_decision` renders (objectui#12342). `@objectstack/spec` 18 declares it, and until now a page naming it passed the spec and drew nothing, because no… (objectui `52d61943c`) + - **minor** — A user whose administrator forced a password change gets the change-password overlay instead of raw `403` responses (objectui#12365). (objectui `37db09f13`) + - **minor** — `element:repeater` reads its query from the node-level `dataSource` binding alone (objectui#12345). `@objectstack/spec` 18 retired the row's flat `object`, `filter`, `sort` and `l… (objectui `23bf8596e`) + - **minor** — **BREAKING** — The view-level `provider: 'api'` data source is no longer read anywhere in objectui (objectui#12325). `@objectstack/spec` retires it (objectstack#23082, ADR-0049): a view binds it… (objectui `524136ea7`) + - **minor** — `NamedListView` declares `requiredPermissions`, the list view's audience key from `@objectstack/spec` 18 (objectui#12341, the objectui half of objectstack#22611 ruling A). (objectui `f2b17c640`) + - **minor** — A page that declares `print` now prints as a document, and a record page bound to such a page offers a Print action (objectui#11958, the objectui half of the print-page ruling on… (objectui `2312bd9a6`) + - **minor** — A file the reader may not open now renders a restricted state instead of a generic file chip (objectui#12339). (objectui `065a1cfbf`) + - **minor** — Studio writes the flow spellings `@objectstack/spec` 18 accepts (objectui#11824, ADR-0032 §3). (objectui `cfb294739`) + - **patch** — A `?tab=` deep link on a metadata item page now opens the sheet it names, and the URL keeps `tab` afterwards (objectui#12496). (objectui `9c2785b6a`) + - **patch** — The AI build page no longer reads `?handoffPrompt=` from the URL, so a link can no longer send a message to the build agent on the signed-in user's behalf; the in-app "Open in Bui… (objectui `b51242899`) + - **patch** — Studio flow checker: in a CEL slot (an edge or node `condition`, a decision branch `expression`, a CEL value) the roots the flow engine binds on every run, `previous`, `vars` and… (objectui `2bacbf08b`) + - **patch** — A list's density and hidden fields are now each user's own, like its sort and column layout, and follow them to another browser (objectui#12474). (objectui `0656b0e3c`) + - **patch** — A `markdown`, `html` or `richtext` cell keeps the grid's one-line row (objectui#12480). The grid's one-line clamp bounded inline content only, so a long rich-content value (a mult… (objectui `c3de0bc73`) + - **patch** — Studio Data pillar, Form tab: one compact toolbar row and card-style sections and fields (objectui#12451). (objectui `68c9b4008`) + - **patch** — Setup catalog editors show History and Audit only to a caller the authoring-trail doors admit (objectui#12475). objectstack#23546 narrows `GET /meta/:type/:name/history`, `/audit`… (objectui `720f6d475`) + - **patch** — Studio's page-create seed and slotted page preview now build an object's default record page with the same detail sections the running app uses. On an object that declares no `fie… (objectui `3e2394d76`) + - **patch** — A browser tab left hidden for 10 minutes stops polling the console's shared feeds, so a forgotten tab no longer keeps a scale-to-zero backend awake (objectui#12439). (objectui `27411ead4`) + - **patch** — A `record:details` node with neither `sections` nor `fields` now draws the object's `highlightFields` (objectui#12433). (objectui `9782cb226`) + - **patch** — An app opens on the first navigation entry the member is shown, not the first entry the server serves (objectui#12426). (objectui `6fcb2ced5`) + - **patch** — An approval request reads the same way on every console surface that opens it, and the Timeline tab of its served page shows its count (objectui#12045, the page-mount step of B1 i… (objectui `bedf41c1c`) + - **patch** — `usePageAssignment` opens an object's default record page: among the record pages served for the object, the one declared `isDefault: true` is returned, and declaration order deci… (objectui `92f979deb`) + - **patch** — A refused "Rename view" now says why (objectui#12417). The object page's view tabs showed only the translated "Rename failed" title for any refusal, and the server's reason reache… (objectui `63079f4a0`) + - **patch** — `deleteView` now shows the server's reason when the published half of a view delete is refused (objectui#12416). (objectui `cfbca7f5b`) + - **patch** — Studio names a draft-only item by its label, with the machine name as secondary text (objectui#11862, the remainder after objectui#11906). (objectui `7c13d3052`) + - **patch** — A refused "Delete view" now says why (objectui#12398). The object page's view tabs showed only the translated "Delete failed" title for any refusal, and the server's reason reache… (objectui `4b0a087a6`) + - **patch** — Setup › Apps: deleting an app names the package whose row it removes (objectui#12397, the consumer half of objectstack#23293). (objectui `5c94723b6`) + - **patch** — The developer Public Forms page lists a form as published exactly when the server's anonymous form routes serve it (objectui#11545). (objectui `6a09fdd0a`) + - **patch** — A grid that fetches its own rows and is tinted by a field that is not one of its columns now colours its rows (objectui#12393). (objectui `2fc31ea94`) + - **patch** — Flow designer: the analysis passes stop modelling the retired single-brace dialect (objectui#12390). A node rename now rewrites only CEL and `{{ }}` holes, so a literal `{x.y}` in… (objectui `4b496cddc`) + - **patch** — Studio stops conflating two members of one bundle. The pending-changes sheet reads each draft's `member` off `GET /meta/_drafts` (objectstack#23073) and lists two languages of one… (objectui `0ddc78888`) + - **patch** — A console reset, delete or draft discard names the package whose row it removes (objectui#12335, the consumer half of objectstack#23165). (objectui `d12bc5fe9`) + - **patch** — **Import Wizard preview: with *Keep unknown option values* ticked, an unknown option value on a writable field is no longer marked** (objectui#11970). The server now keeps such a… (objectui `3f20d5aa2`) + - **patch** — A grid row coloured by `rowColor` now reads in the dark theme, and its colour covers the whole row (objectui#12375). (objectui `70e843fa4`) + - **patch** — Studio keeps a `controlled_by_parent` object savable on `@objectstack/spec` 18 (objectui#12326, the objectui half of objectstack#23100). (objectui `28fb2a001`) + - **patch** — A list view tinted by a field that is not one of its columns now colours its rows (objectui#12376). (objectui `a311b6a9e`) + - **patch** — A comment edit on the record feed sends only the new text and its mentions (objectui#12314, the objectui half of ruling A on objectstack-ai/objectstack#23124). The console used to… (objectui `aafaf9c10`) + - **patch** — Published text no longer teaches the retired view-level `provider: 'api'` data source (objectui#12364). A view binds `provider: 'object'` or `provider: 'value'`; a metadata-declar… (objectui `d75f21285`) + - **patch** — fix(fields): a text-valued formula is drawn as text, not in a monospace code font (objectui#12357) (objectui `789cb166c`) + - **patch** — A New form now saves the owner the user picks (objectui#12359). When the form's layout shows Owner, the user could pick a user and see that user's chip. The create request still c… (objectui `fc211e817`) + - **patch** — fix(plugin-grid): a list column's inline locale-map `label` now heads the column (objectui#12358) (objectui `1040a0a13`) + - **patch** — A bulk action's confirm step now lists the affected records by their display title (the object's `nameField`, through the shared ADR-0079 resolver) instead of raw ids, for objects… (objectui `df05784ae`) + - **patch** — Studio holds a Notify step whose Title is blank, the way it already holds a missing one (objectui#12343, the objectui half of `@objectstack/spec` 18 refusing a blank Notify title). (objectui `ccd7e94a8`) + + ⚠️ 5 of these carry a breaking change: 1 by a declared `major` level, 4 by the author's own breaking annotation in the changeset body. Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 3 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ docs(design): Studio design-surface reference artboards for the layout redesign (epic #12446) (#12458) (objectui `cfafe052b`) + - _(no changeset)_ ci(release): correct the clear step's pre-mode comment for Changesets 3 (#12434) (objectui `6843f7599`) + - _(no changeset)_ ci(test): widen the test matrix from 12 to 16 legs, and move every pin of the width (objectui#12384) (#12399) (objectui `140916c5c`) + + Clause-②: no + + + + objectui range: `b9814b334d8a...1f23d5b070fa` + +### Minor Changes + +- 140238a: Console (objectui) refreshed to `f96f771140f1`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 31 releasing of 31 changesets added across 31 non-merge commits. + + - **minor** — The object list's chrome sits on fewer rows (objectui#12528). (objectui `646caea87`) + - **minor** — **A dashboard's "Refresh All" sits in the console page header** (objectui#12554). The console's dashboard page draws the button at the right of its page header's one row, among it… (objectui `96dea1b7a`) + - **minor** — Console pages: a dashboard and the Approvals Inbox draw the page header an object list draws, and the top bar draws no breadcrumb on a dashboard or page route (objectui#12526). (objectui `cd64d43f1`) + - **minor** — Studio Interfaces pillar: the rail is the app's navigation tree as the running sidebar draws it, the "+" offers every nav kind, and the header, tab row and nav-item inspector foll… (objectui `b5416943c`) + - **minor** — The flow canvas's node cards and the Studio's node configuration aside take the Automations redesign (objectui#12488, epic objectui#12446). (objectui `a27c1cfb8`) + - **patch** — A dataset-bound metric tile on `DashboardGridLayout` is two lines, not three (objectui#12573). (objectui `f96f77114`) + - **patch** — Record details: Enter on a link inside a read-only details row that offers copy now opens the link, instead of copying the row's value (objectui#12569). (objectui `6f5dc6b30`) + - **patch** — Studio's Data pillar Form tab now previews the draft (objectui#12515). (objectui `e089a25b9`) + - **patch** — List toolbar: the Filter, Export and Add record buttons keep an accessible name when they render icon-only below the `sm` breakpoint (objectui#12500). Each carries an `aria-label`… (objectui `3821a3ac5`) + - **patch** — The approval decision panel's declared amount also takes a roll-up total (objectui#12534): a field the target object declares `type: 'summary'` whose `summaryOperations` is a `sum… (objectui `cf0f40323`) + - **patch** — A dataset-bound metric tile on a dashboard is two lines, not four (objectui#12528). (objectui `d88e3517e`) + - **patch** — Opening a collapsed section in a record dialog no longer takes the whole console off screen (objectui#12559). (objectui `0081cdc9e`) + - **patch** — The Studio's Interfaces canvas no longer asks for the schema of an object that has only a draft (objectui#12508). (objectui `1b28dbf27`) + - **patch** — Console pages: Flow Runs, Integrations & APIs, Audit Log, AI Approvals, Applications, Profile, the Settings hub and each settings namespace page draw the console page header an ob… (objectui `0d2ac9055`) + - **patch** — A record pointer card now links to the record it shows, when the reader may read that record (objectui#12557). (objectui `7f535f86a`) + - **patch** — Hiding columns on your own saved view now sticks, even when the view declares no `columns` (objectui#12505). (objectui `572bbce00`) + - **patch** — A read-only package now says it is read-only in two places in the Studio, the same in every pillar (objectui#12453). (objectui `2a692851a`) + - **patch** — Studio Interfaces: editing the navigation keeps the 240 px rail, so the content column and the inspector no longer move when you press Edit or Done. Each row of the navigation edi… (objectui `73a3eb2b9`) + - **patch** — A console page's "Edit in studio" button opens the page in Studio, where every other Studio handle on the same screen goes (objectui#12540). (objectui `a90c13178`) + - **patch** — Studio no longer logs a red "Failed to load resource" line for things it already knows are absent (objectui#12508). (objectui `d17952c06`) + - **patch** — The object designer (Studio's Object Settings) and Setup's app navigation editor pick an icon from the catalog the console ships instead of taking a typed Lucide name, and the ico… (objectui `93ce81ac0`) + - **patch** — A sectioned create or edit form draws every section at its own `columns` count per row, whatever the other sections declare (objectui#12548). (objectui `5f8ecf678`) + - **patch** — Flow canvas: a branch edge's pill reads the edge's own label first, so two outcomes of one decision no longer show the same truncated condition (objectui#12507). (objectui `baab2d6ed`) + - **patch** — The record page's highlight strip no longer cuts a value without saying so, and at phone width it uses one layout (objectui#12523). (objectui `518a8e308`) + - **patch** — The console list page's saved-view tabs show as many tabs as the toolbar row has room for again, instead of folding to one tab and "N more" (objectui#12518). (objectui `92318202a`) + - **patch** — The console sidebar's ⋯ menu Design entry, and a favorite's Design entry, open the item's Studio surface: the same destination the app bar's Edit app opens for that item (objectui… (objectui `646307f37`) + - **patch** — Studio Data pillar: the object header shows the same controls on every tab (objectui#12511). (objectui `5249c2435`) + - **patch** — The Studio header no longer overlaps on a narrow window (objectui#12509). (objectui `a3d14e787`) + - **patch** — New package dialog: the display name is now the first field, and the Package ID, Object namespace and Version follow it in the same Basics card. Each of the three says it was fill… (objectui `eea0993c1`) + - **patch** — Record page History: an entry with no actor at all (neither a resolved name nor a user id — a seed backfill or a hook with no user in context) now reads "System" with a cog glyph… (objectui `165f8ff9f`) + - **patch** — Record page header `⋯` menu: the system entries (Edit, favorites, Share, Delete) now carry icons (pencil, star, share, trash) like the app's own actions; the menu lists authored a… (objectui `1ffd6a2fc`) + + objectui range: `1f23d5b070fa...f96f771140f1` + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index f1828aa316c..e4d2b97eba8 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 a205d6e49fb..057b5815b5c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,223 @@ # @objectstack/core +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 086fa46: feat(cli,plugin-dev): an app under `os serve` or `DevPlugin` that declares its own `sys_organization` default record page boots, and its page is the only default + + Clause-②: yes (widening) + + A deployment holds at most one `isDefault: true` record page per object, and a second one is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`. `plugin-auth` registers `sys_organization_detail` as the default record page of `sys_organization` unless it is constructed with `registerOrganizationRecordPage: false`. `os serve` and `DevPlugin` construct `AuthPlugin` themselves and never passed that option, so an app served by either one that shipped its own default page for `sys_organization` was refused at boot, with no switch to set. + + - **What changes.** Both roots now read the app's own pages before they construct `AuthPlugin`. When the app declares an `isDefault: true` record page for `sys_organization`, they pass `registerOrganizationRecordPage: false`, and the app's page is the only default. When it does not, `plugin-auth`'s page registers as before. There is no new configuration key: the page the author already wrote is the switch. Plugin order and precedence rules play no part. + - **One rule for both roots.** The reading is `stackDeclaresOrganizationRecordPage(stack, plugins?)`, a new export of `@objectstack/core`. It reads the stack's `pages` (or each package body's, for a multi-package stack) and the bundle of each app plugin already in `plugins`, and judges each page with `defaultRecordPageObject` from `@objectstack/spec/ui`, the reading the registration refusal uses. + - **The refusal's remedy (`@objectstack/objectql`).** `DEFAULT_RECORD_PAGE_CONFLICT`'s message now says to set `isDefault: false` on one of the two pages, "or compose the deployment without the other package's page". It said to compose the deployment without the other package, which a host cannot do when that package is the platform's auth plugin. The code, the `422` status and the named pages are unchanged. + - **Nothing to adopt.** An app that ships its own `sys_organization` default record page boots under `os serve` and `DevPlugin` with no further change. A deployment that composes its own kernel still sets `registerOrganizationRecordPage: false` on `AuthPlugin` itself. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- daafae6: fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call + + Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal. + + + + **BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not. + + - **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there. + - **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart. + - **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change. + - **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`. + - **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire. + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both. + - **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered). +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. + +### Patch Changes + +- 0d3ea29: fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + + Clause-②: no + + Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + + - **`@objectstack/plugin-security`.** + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. + - **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. + - **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. + - **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. + - **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. +- be48c5e: feat(spec)!: retire the delegated-admin scope's `manageBindings` and `authorEnvironmentSets` — rights no door honoured (#23488) + + **BREAKING** — `manageBindings` and `authorEnvironmentSets` on a permission set's `adminScope` (`AdminScopeSchema`, ADR-0090 D12) are now refused at parse. They named the right to bind sets to positions and the right to author environment permission sets, and they gated the `sys_position_permission_set` and `sys_permission_set` rows, which ADR-0131 D3 retired. A position's sets are part of its definition and a permission set is a definition; both are written through the metadata door, which admits a `manage_metadata` holder and reads no `adminScope`. So a delegate who "may bind" or "may author" was granted nothing. Delete both keys: a delegate's scope is placement, assignment and the allowlist. + + Clause-②: yes (narrowing) + + Measured before removal, at `cdc42eb49`: no production author set either key to `true` (the showcase wrote the default `false`; the control key `manageAssignments: true` has four production authors), and no door that writes a position or a permission set consulted either key. Their one reader was the delegated-admin gate's strict-containment comparison, which changes below. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `adminScope: { …, manageBindings: true \| false }` | nothing — delete the key | + | `adminScope: { …, authorEnvironmentSets: true \| false }` | nothing — delete the key | + | a delegate who re-composes what a position distributes, or authors a set's body | a `manage_metadata` holder writes the position or set definition through the metadata door (ADR-0131 D3); the delegate assigns people to positions and grants allowlisted sets inside its subtree | + | reading `scopes[].manageBindings` / `scopes[].authorEnvironmentSets` from `GET /api/v1/security/my-delegable-scope` (`DelegableAdminScope`) | nothing — the report carries `manageAssignments` as the scope's one action right | + + **The one-line fix:** delete `manageBindings` and `authorEnvironmentSets` from every `adminScope`. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`AdminScope` types both keys as retired), and the parse — `definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name` — refuses it at its own path with the prescription: + + > `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The containment change (`@objectstack/plugin-security`) + + The delegated-admin gate judges a delegate who hands out a set that itself carries an `adminScope` — a `sys_user_position` assignment of a position that distributes it, or a direct `sys_user_permission_set` grant — by **strict containment**. That comparison read both retired flags. It now reads `includeSubtree`, `manageAssignments` and the allowlist only: + + - a delegate whose own scope set either flag `true` can **no longer** hand out a scope otherwise EQUAL to its own — the inert flag used to count as the strictly-larger axis and let that lateral copy through the equal-scope refusal. It is refused `403 PERMISSION_DENIED` (`… would hand out an adminScope not strictly contained by yours`); + - a granted scope that set either flag `true` no longer counts as out-reaching a delegate that did not. + + `DelegatedAdminGate.describeDelegableScope()` (and so `GET /api/v1/security/my-delegable-scope`) no longer reports either flag. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `AdminScopeSchema`, a `strictObject` — so each refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/AdminScope:manageBindings`, `security/AdminScope:authorEnvironmentSets`. `DelegableAdminScope` (`@objectstack/spec/contracts`) drops both fields. + - **The D2 conversion `permission-admin-scope-binding-authoring-removed`** (protocol 18, retired from the load path) deletes both keys from every `permissions[].adminScope`, one notice per key, whatever they hold. Both keys carried the default `false`, so a 17.x parse materialized them in every declared scope; the rehydration seams replay this conversion over stored `permission` rows and built artifacts, and `os migrate meta --stored` lists the rows that carry them before the upgrade. + - **The D3 entry `permission-admin-scope-binding-authoring-retired`** records the containment change above: the judgement the strip cannot make for the upgrader. + - **`@objectstack/core`:** `permissionSetDefinitionFromRow` — the mapping `os migrate`'s legacy-row ceremony saves a `sys_permission_set` row through — drops both keys from the row's `admin_scope`. That save goes through `saveMetaItem`, which replays no conversion: measured, a 17.x row's body answered `422 INVALID_METADATA` at `adminScope.manageBindings`, and the same body without the keys saved. + - **`@objectstack/platform-objects`:** the permission-set form's `adminScope` help text (all four locales) names the scope's live keys only. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's two switches for these keys retire in an objectui follow-up. + + +- 8044f4d: fix(core): a bootstrap that fails after every plugin started rolls them back too, so a driver connected in `start()` is disconnected + + Clause-②: no + + `ObjectKernel.bootstrap()` rolled back started plugins only when a plugin's `start()` failed. A boot refused later (the system-requirement check, or a `kernel:ready`, `kernel:bootstrapped` or `kernel:listening` handler that throws) left the kernel `stopped` with every plugin still holding what its `start()` took: a datasource driver stayed connected, because `shutdown()` on a stopped kernel is a no-op and nothing else ran any `destroy()`. + + - **What changes.** A boot that fails after the last `start()` now destroys the plugins that started, in reverse start order, through the same rollback a failing `start()` already ran. One teardown answers a failed boot at whatever phase it fails. A kernel built with `rollbackOnFailure: false` keeps opting out of both. + - **What does not change.** + - A readiness or hook failure still rejects with its own error, unwrapped; a failing `start()` still rejects with the error naming the plugin. + - A `destroy()` that throws during the rollback is logged at `error` and does not replace the boot error; the plugins started before it are still destroyed. + - A plugin whose `start()` failed or never ran is not destroyed. + - A failed `start()` still rolls back exactly once. + - `shutdown()` after any failed boot is still a no-op, so nothing is destroyed twice. + - **Ownership of the teardown.** While the rollback runs the kernel reads `stopping`, so a `shutdown()` or a signal that arrives meanwhile is absorbed. A `shutdown()` that already began mid-boot owns the destroys, and the failed boot adds none. + - **Who is affected.** In-process hosts that await a boot which can be refused after connect: `Runtime.start()`, `bootStack`, tests. `os serve` exits on a refused boot and is not. + - **What to check in a plugin.** A `destroy()` may now run on a plugin whose `kernel:ready` handler never ran, the same tolerance `bootPhaseHooks: false` already asks for. A host that closed such a resource by hand after a refused boot now closes it a second time, so that close should tolerate an already-closed resource. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 0fc25877da4..f094ec2f24d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Microkernel Core for ObjectStack", "type": "module", diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md index 78297a81256..b6a4b7d23e2 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,9 @@ # create-objectstack +## 18.0.0-next.2 + +No changes in this release. + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index fb0cfb64097..00701ccce43 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 b5bec572604..826fc64c0a5 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,78 @@ # @objectstack/driver-memory +## 18.0.0-next.2 + +### Minor Changes + +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. + +### Patch Changes + +- f77ba41: fix(driver-memory): a composed `ReadableIds` leaf hashes its readable ids once per query, not once per row, so a read under a large readable-parent set no longer costs rows × parents (ADR-0139 D3) + + Clause-②: no + + - **Before.** The driver rewrote a composed `ReadableIds` leaf into the ids of every readable parent row, with no cap, and handed mingo that list as a plain `$in`. mingo's `$in` rebuilds a hash map of its whole list for every document it tests. So a read over R child rows under N readable parents made R × N hash inserts: with 2,700 rows and 2,400 readable parents, one `find`, `count` or `aggregate` took about a second. + - **After.** The leaf's list lowers to a member test that hashes it once, when the query compiles. The same read takes milliseconds. This holds on every door that evaluates the leaf (`find`, `count`, `aggregate`, `distinct`, `updateMany`, `deleteMany`) and under `$and`, `$or` and `$not`. + - **Answers are unchanged.** The member test keeps every rule of mingo's `$in`: a nil value matches only a literal `null` member, an array value matches when any element is a member, objects compare by structure, and string values are tested against RegExp members. + - **Scope.** Every other `$in` is still mingo's own operator. Only the list the leaf was rewritten into takes the new test. The test's operator name cannot be written in a filter: the filter shape gate refuses it. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index bbdb47f3992..52f0a481d55 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 de8de7b8677..1412f4b7bee 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,89 @@ # @objectstack/driver-mongodb +## 18.0.0-next.2 + +### Minor Changes + +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. + +### Patch Changes + +- bc0538a: feat(spec)!: the datasource driver spelling `mongo` is retired — an authored `driver: 'mongo'` is refused with the prescription naming `mongodb`, `OS_DATABASE_DRIVER=mongo` is no longer a driver selection, and `datasource-driver-mongo-to-mongodb` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `mongo` was a second accepted spelling of the MongoDB driver id `mongodb` through 17: the loader rewrote an authored `datasource.driver: 'mongo'` to `mongodb` with a notice, and both boot hosts accepted `OS_DATABASE_DRIVER=mongo`. At 18 it is retired. `DriverType` refuses it by name at every door that parses a datasource (`defineStack`, `os validate`, `os build`, `os lint`, the metadata and datasource APIs): "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `driver: 'mongodb'`." The refusal is case- and whitespace-insensitive, and it is published in the JSON Schema too (a `pattern` on `driver`), so a document validated against `json-schema/**` is refused as well. Both boot hosts now refuse `OS_DATABASE_DRIVER=mongo` (and a standalone stack's `databaseDriver: 'mongo'`) with their "Supported drivers" list, which names `mongodb`. The ADR-0087 D2 conversion `datasource-driver-mongo-to-mongodb` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the spelling. + + Migration: + + - `datasource.driver: 'mongo'` → `driver: 'mongodb'`. Change the value; the driver it builds and the `config` contract it is judged against are unchanged. + - `OS_DATABASE_DRIVER=mongo` → `OS_DATABASE_DRIVER=mongodb`, or unset it and let a `mongodb://` or `mongodb+srv://` database URL select the driver. The same for `databaseDriver: 'mongo'` passed to the standalone stack. + - A script or integration that creates or updates a datasource through the metadata or datasource REST API with `driver: 'mongo'` → send `driver: 'mongodb'`. + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. The D3 entry `datasource-driver-mongo-alias-retired` lists what the chain cannot reach: environment variables, launch scripts and API callers. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` datasource row written as `mongo` (the Setup → Datasources form wrote that until the canonical id was renamed to `mongodb`) keeps loading as `mongodb`. The spelling also stays on the spec's driver CONTRACT face (`resolveDriverId('mongo')` still answers `mongodb`): a reader that judges a stored body as written, such as the read-path credential redactor, keeps judging a legacy row as mongodb. Measured with the spelling deleted outright, that redactor served all ten mongodb passthrough secrets of such a row in cleartext. + + `@objectstack/spec` also exports `RETIRED_DRIVER_ID_GUIDANCE` (retired spelling → its prescription), the table the refusal is derived from. `@objectstack/driver-mongodb`'s README no longer lists `mongo` as a recognised `OS_DATABASE_DRIVER` value. + + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 292601daf64..373d5596cbf 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client", "main": "dist/index.js", diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md index d2e03c796fc..51d8a08c85b 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/driver-sql +## 18.0.0-next.2 + +### Minor Changes + +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. + +### Patch Changes + +- 53646fc: fix(driver-sql): a SQLite database file the driver cannot create or open fails `connect()` at once, naming the file and the operating system's error (#23696) + + Clause-②: no + + A declared SQLite datasource whose `config.filename` lay under a directory the operating system will not make (for example `/proc/x/ds.db`) never finished connecting. The driver made the directory with Node's recursive `mkdir`, which loops without end on such a path: `mkdir` of the level answers `ENOENT`, its parent answers `EEXIST`, and it retries, about 1,600 failing calls a second, all system CPU. The boot was then refused only by a plugin start timeout (30 s, or 120 s when a `datasourceMapping` rule routes to the datasource), and that message named neither the datasource, the path nor the error. The same happened to the host's own database when `OS_DATABASE_URL=file:` named such a path. + + **What changes** + + - The directory is made by a walk bounded by the path's depth, one non-recursive `mkdir` per missing level. The first level the operating system refuses fails `connect()` with a `SqliteDatabaseFileError` that names the file, the directory and the cause (`ENOENT`, `EACCES`, `ENOTDIR`, …). + - A file SQLite itself cannot open (`SQLITE_CANTOPEN`, for example a filename that is a directory) now fails `connect()` the same way. Before, the error was logged as a warning and `connect()` resolved, so the datasource counted as connected while it could not answer one query. A native-addon load failure keeps its warning and the step-down to wasm SQLite. + + The boot's existing connect refusal (ADR-0062 D5) carries this message, so `objectstack serve` on such a config now prints, at once, `datasource 'primary': connect failed — SQLite database file '/proc/x/ds.db' cannot be created: its directory '/proc/x' could not be made: ENOENT: no such file or directory, mkdir '/proc/x'`. No configuration key or accepted value changes. A path the operating system can create still connects. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index ba3b1311027..bd4f78a9053 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 693040393f1..f627e9d5e75 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,78 @@ # @objectstack/driver-sqlite-wasm +## 18.0.0-next.2 + +### Minor Changes + +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. + +### Patch Changes + +- c7dd48a: fix(driver-sqlite-wasm): a database-file write that fails in the background is reported once, at `error` with the file named, instead of reaching the process as an unhandled rejection + + `WasmSqliteConnection` writes the in-memory image to disk without anyone awaiting it in three places: after each write under `persist: 'on-write'`, when the `debounced:N` timer fires, and when a transaction closes with a flush deferred. A write that failed there (the disk full, the data directory removed under a live driver) had no caller to throw to, so its rejection reached the host process as an `unhandledRejection`: a failed run under Vitest, and a terminated process under Node's default. + + Those three flushes now go through one helper. A failed one is logged once per failure episode, at `error` where the logger has one and at `warn` where it does not, naming the database file and saying that writes since the last good flush are not on disk. The file stays marked dirty, so the next write's flush (or `close()`) retries it and lands once the cause is gone. Further failures stay quiet until a flush has succeeded. + + An awaited `flush()` and `close()` are unchanged and still throw to their caller. The `logger` option's type is unchanged; a logger that only has `warn` still hears the report, at `warn`. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [53646fc] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/driver-sql@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index 17cbcdd9948..8f2d3c52521 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": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 4472cee2c5d..ae21a3ae5b2 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/driver-turso +## 18.0.0-next.2 + +### Minor Changes + +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [53646fc] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/driver-sql@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 0dfd66a27a9..93983321244 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 da350a53d0f..a5fff53a276 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/formula +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 45c887b7bbd..80bd4de8765 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry", "main": "dist/index.js", diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md index 0b1b9c45ca8..007e33dd766 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,251 @@ # @objectstack/lint +## 18.0.0-next.2 + +### Minor Changes + +- 4e0838d: fix(spec,lint)!: an `objectExtensions` entry that leaves a `controlled_by_parent` object with anything but one required `master_detail` is refused, as the same object declared directly already was (#23210) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0055 §1: a `controlled_by_parent` object derives its master from exactly one `master_detail` field declared `required: true`. `ObjectSchema` refuses an object that declares anything else, and so does the security-posture lint. Neither looked at the object the runtime actually runs: at boot the registry merges every `objectExtensions` entry onto its target, field by field, so an extension could add a second required `master_detail`, or demote the one master. `defineStack`, `os validate` and the lint passed the stack, and the runtime then derived no master: every read of the object denied, every write refused with `422 INVALID_METADATA`. + + **What is refused, and where.** + + - **`@objectstack/spec`.** `ObjectStackDefinitionSchema` (so `defineStack`, `os validate`, `os build`, the lint scorer and an artifact load) judges the rule on the merged field set of every `controlled_by_parent` object that an `objectExtensions` entry of the same stack extends. In a multi-package artifact (`packages[]`), an extension in any package judges an object any package declares. The merge is read as the registry computes it: fields by name, a later writer replacing the whole field, extensions in ascending `priority`. The issue sits at the `fields` of the extension that wrote the deciding field (`objectExtensions.0.fields`, or `packages.0.manifest.objectExtensions.0.fields`), with the message the same object gets when declared directly. `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **`@objectstack/lint`.** `security-controlled-by-parent-ambiguous-relation` and `security-controlled-by-parent-no-relation` judge the same merged shape and report it at the same extension, in the words of the declared object's finding. + + No export, signature or error code is added or removed. + + ## FROM → TO + + | stack | FROM | TO | + |:--|:--|:--| + | `objects: [{ name: 'cbpx_line', sharingModel: 'controlled_by_parent', fields: { header: { type: 'master_detail', reference: 'cbpx_header', required: true } } }, …]`, `objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'master_detail', reference: 'cbpx_other', required: true } } }]` | accepted; at runtime the object derived no master and failed closed | refused at `objectExtensions.0.fields`: "Object 'cbpx_line' declares sharingModel 'controlled_by_parent' with 2 required `master_detail` fields (`header`, `other`)…" | + | the same object, extended with `fields: { header: { type: 'master_detail', reference: 'cbpx_header', required: false } }` | accepted; no master at runtime | refused at `objectExtensions.0.fields`: "…but no required `master_detail` field…" | + | an artifact built before this change that carries either shape | loaded; the object failed closed | refused at load; rebuild it from the corrected source | + + **Remedy, in one line.** In the extension, drop the second master or declare it `type: 'lookup'`, and never redeclare the object's master with `required: false` or another type. + + **Unchanged.** + + - An extension adding any non-master field (a `lookup` to another parent included) to a `controlled_by_parent` object. + - An extension whose target the stack does not declare: another package's object, or the platform's. It is not judged, and the runtime's fail-closed refusal remains the backstop there. + - An object already refused as declared is reported once, at the object. + - In a multi-package artifact, a target on which two packages write one field at the same `priority`: the winner is the order the artifact loader registers them in, which the parse does not compute, so it is not judged. + - Stored `sys_metadata` rows: an extension is merged only when a stack or an artifact registers, so no stored row carries the shape and none is rewritten. + - The step-18 entry `cbp-extension-merged-master-refused` carries the prescription for `os migrate meta`. It is asked only of a stack that declares `objectExtensions`. +- 54af6c0: feat(lint): export the dataset dimension leg's group-key classifier, so a consumer asks the lint instead of composing the analytics door's two predicates itself (#23235) + + Clause-②: yes + + `@objectstack/lint` now exports `groupKeyClassOf(shape)` and its result type `GroupKeyClass`: + + - `groupKeyClassOf(shape)` answers "may a dataset or cube dimension group by a column declared this way?". `null` means yes: the column stores one scalar value, and the analytics door serves it as a group key. Otherwise it names the JSON-stored class the door refuses with `400 INVALID_FIELD`: `'structured-json'` (a type in `STRUCTURED_JSON_TYPES`) or `'multi-value'` (`isMultiValueField`: `multiselect`, `checkboxes`, `tags`, or a multi-capable type such as `select` declared `multiple: true`). The two spec predicates are applied in the door's order. + - `shape` is `ValueShapeFieldDef` from `@objectstack/spec/data`, the slice of a field definition the predicates read (`type`, `multiple`). A declared `Field` passes as it is. The classifier reads no object graph: for a dotted path, pass the leaf field's definition. + + A consumer that derives dataset dimensions keeps a field as a group key when `groupKeyClassOf(field) === null`, and gets the lint's own verdict. The `dimension-json-stored-field-refused` rule (dataset dimensions and cube dimensions) keeps calling the same function; on the rule's own fixtures, every declared field type with and without `multiple: true` and every column of the cube fixture, the export and the rule agree on the verdict and the class. The rule's findings, paths and texts are unchanged. + + No export is removed, and no accepted or refused shape changes. +- 301eec6: feat(spec)!: retire the object's `externalSharingModel` — the external org-wide default no principal ever read (#23268) + + **BREAKING** — `externalSharingModel` on an object (`ObjectSchema`, the `object` metadata type) is now refused at parse. ADR-0090 D11 declared it as a second org-wide default for portal and partner principals, and nothing ever evaluated it: no request resolves an external principal, and the authorization kernel's `EXTERNAL` rung (ADR-0095 / ADR-0099) admits explicitly shared rows only, never an org-wide default. Setting `externalSharingModel: 'private'` hid nothing from anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share (a manual share or a sharing rule), and an anonymous reader through a share link (`publicSharing`, unchanged). + + Clause-②: no (narrowing) + + Measured before removal, at `e4ec6ec1a`: no row-pipeline reader in any package. The key's only readers were authoring checks of its own ordering against `sharingModel` — the publish linter's alias and wider-than-internal branches, and the `plugin-security` posture gate's env-tighten comparison — plus the Studio console's object settings select (a separate console change). The only authored instances in this repository were two showcase objects. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `externalSharingModel: 'private'` (or `'public_read'`, `'public_read_write'`, `'controlled_by_parent'`) on an object | nothing — delete the key; share rows explicitly where an outside principal needs them | + | `import { SECURITY_EXTERNAL_WIDER } from '@objectstack/lint'` | nothing — the rule `security-external-wider-than-internal` is removed with the key it judged | + + **The one-line fix:** delete `externalSharingModel` from every object. + + **What an author who still writes it sees.** `tsc` fails at the authoring site (`ObjectSchema` types the key as retired), and the parse — `ObjectSchema.create()`, `defineStack({ objects })`, `PUT /api/v1/meta/object/:name` (draft saves included) — refuses it at its own path with the prescription: + + > `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The retirement kit + + - **A `retiredKey()` tombstone** on `ObjectSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `data/Object:externalSharingModel`. + - **The D2 conversion `object-external-sharing-model-removed`** (protocol 18, retired from the load path) deletes the key from every `objects[]` entry, one notice per object, as a lossless delete. Studio's object settings offered the select and `PUT /api/v1/meta/object` persisted it until this release, so a stored `object` row can carry it; the rehydration seams replay this conversion over stored rows, and `os migrate meta --stored` lists the rows that carry it before the upgrade. + - **The D3 entry `object-external-sharing-model-retired`** asks the author whether anything they built assumed the key kept outside users away from an object's rows. Nothing ever enforced that. + - **Ledger:** the row goes `planned` → `dead`, kept as a tombstone row (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/lint`:** the `security-external-wider-than-internal` rule and its exported `SECURITY_EXTERNAL_WIDER` id are removed, and `security-owd-alias` no longer reads `externalSharingModel`; the parse refuses the key on every parsed door. On an unparsed intake (`os lint` on a raw config) the ledger-driven advisory reports it as `liveness-dead-property`. + + **`@objectstack/plugin-security`:** the object posture gate's env-tighten-only rule (R1) compares `sharingModel` alone; the external half left with the key. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- d0c09e4: The metadata save door runs the two page component rules that `objectstack validate`, `objectstack build` and `objectstack lint` run. A page saved through Studio, REST `/meta` or an MCP/AI author whose components carry an undeclared, wrong-typed or retired property (`component-props-unknown-key`, `component-props-invalid`), or a component `type` inside a spec-reserved namespace that the platform does not declare (`component-type-unknown`), is refused with `422 INVALID_METADATA`, and each issue carries the fix the CLI already prints. Before, the save answered `200` and the renderer ignored the property or drew a placeholder. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on the metadata save door (`PUT /api/v1/meta/page/:name`, the draft promotion `POST /api/v1/meta/page/:name/publish`, the package publish, and every in-process `saveMetaItem` caller on an environment's authoring channel), shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode). + + **The next save of a stored page whose components carry an undeclared, wrong-typed or retired property, or a reserved-namespace type the platform does not declare, is refused with the fix; stored pages keep serving until then.** The door judges writes only: a stored page row is not re-judged at boot, loads and is served as before, and a draft save is not judged until it is published. + + ## FROM → TO + + | you saved | the door now answers | write instead | + |:--|:--|:--| + | `page:header` with `properties: { titel: 'Deals' }` | `422`, `component-props-unknown-key` at `…properties.titel`, offering `title` | `properties: { title: 'Deals' }` | + | `record:related_list` with `properties: { limit: 'five' }` | `422`, `component-props-invalid`, naming the expected type | `properties: { limit: 5 }` | + | a component typed `page:headr` | `422`, `component-type-unknown`: "Rename `page:headr` → `page:header`." | `type: 'page:header'` | + | a component typed `acme:pipeline_board` (outside the reserved namespaces) | unchanged: saved | nothing | + + **The one-line fix: apply what the issue names, then save again.** If a component really does read the key, the props row in `@objectstack/spec` is what is wrong, not the page. + + **Who is affected.** A deployment whose stored pages carry such a finding: those pages keep loading and serving, and only their next save is refused until the finding is fixed. `objectstack migrate meta --stored` does not list these findings. The authored pages measured when the props findings became errors (37 pages in this repository and 8 in `objectstack-ai/hotcrm`) carried none, and the in-repo page corpus carries no `component-type-unknown` finding; deployed rows were not measured. The two former ADR-0087 D2 aliases on page component properties, `page:header` `description` (write `subtitle`) and an `element:button` inline `type: 'api'` action's object `params` (write `bodyExtra`), are retired from the load path at 18 and refused at every door with the rename (`objectstack validate`, load, and this save door), so the save door and `objectstack validate` agree on them. + + The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above (and the matching step-18 summaries) said a stored page still saves and loads, and now says it still loads and that its next save meets the component-props gate at the metadata save door. +- 30b0fc2: fix(lint)!: a permission set's field-permission key with an empty object part (`'.FIELD'`) is refused as unqualified, at `os validate` and at the metadata save door alike (#23549) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A permission set's `fields` map is keyed `OBJECT.FIELD`, and the security plugin applies a set's field rules to an object by the key prefix `OBJECT.` (`PermissionEvaluator.getFieldPermissions`). A key whose object part is empty, such as `'.full_name'`, matches no object, because no object name can be empty, so the mask it declares never enforces: the field stays exactly as readable and editable as the object-level grant leaves it, for every holder of the set. + + **What changes for `'.FIELD'`.** + + - Before this change, both field-permission rules let the key through. `security-fls-unqualified-key` skipped every key containing a `.`, and `security-fls-unknown-field` read the empty object part as another package's object and did not judge it. `os validate`, `os build` and `os lint` passed the stack, and the metadata save door stored the set with `success: true`. + - Now `security-fls-unqualified-key` reports it, at `error`: a key is object-qualified only when it has a non-empty object part before its first `.`. The finding sits at `permissions[N].fields[".FIELD"]`. Its message says the object part before the first `.` is empty, and its fix line names the `'.'` form, with an example that keeps the remainder as the field when it is one plain name. `security-fls-unknown-field` no longer considers the shape, so each such key earns exactly one finding. The same holds for `'.'`, `'..FIELD'` and `'.FIELD.MORE'`. + - At the save door, a `permission` write in publish mode on the environment authoring channel that carries such a key now answers `422 INVALID_METADATA`, with one `security-fls-unqualified-key` issue. This covers `PUT /api/v1/meta/permission/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`publishMetaItem`), and every other publish-mode save through `saveMetaItem` on that channel: cloning a permission set, `duplicatePackage`, the import of an unmanaged package and the organization-ownership ceremony. A kernel assembled on the `package-author` authoring channel skips this gate; the author-time CLI doors above judge that channel's source instead. + - An `os migrate meta --stored --apply` re-save is not judged: the gate exempts `source: 'migrate-stored'`, so a stored set holding such a key is rewritten into the current dialect unjudged, is not reported `failed`, and keeps loading. Its next ordinary publish-mode save is judged. + + **Remedy.** Put the object's name before the `.`, so `'.full_name'` becomes `'crm_account.full_name'` for the object whose field it masks. Saving the set as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - A qualified key naming a real field (`OBJECT.FIELD`) is accepted as before. + - A key naming an object this stack does not define, such as `no_such_object.x` or `no_such_object.`, is still not judged, because another installed package may declare that object. + - A bare key (`full_name`) is refused under `security-fls-unqualified-key` with the same message and fix line as before; an empty or dangling remainder on an object of this stack (`crm_account.`, `crm_account.owner.name`) is refused under `security-fls-unknown-field` as before. + - Stored rows are not migrated by this change, and they are not refused on read or at boot: the save door judges new writes only. A set stored before this change keeps loading until its next ordinary publish-mode save, which is judged; an `os migrate meta --stored --apply` re-save is not (above). + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - No rule id, error code, export or signature is added or removed. `PermissionSetSchema` is unchanged. + - Measured before this change: no permission set in this repository's examples, templates, skills or fixtures carries a `fields` key that starts with `.`. + +### Patch Changes + +- c911ab6: fix(lint): the 11 dashboard widget-binding rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 11 rule ids (all in `validate-widget-bindings.ts`) now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - dashboard filters: `dashboard-filter-field-unprovisioned` (561 → 177), `dashboard-filter-field-unknown` (403 → 148) and `dashboard-filter-field-not-included` (218 → 144); + - widget filters: `widget-filter-field-unknown` (322 → 133); + - refused `chartConfig` keys: `chart-field-unknown` (415 → 152); + - empty selections: `chart-measures-missing` (369 → 144), `widget-measures-missing` (330 → 154) and `chart-dimensions-missing` (291 → 161); + - legacy analytics keys: `widget-legacy-analytics-shape` (338 → 113) and `widget-legacy-analytics-unrenderable` (320 → 105); + - tables: `table-count-only` (311 → 136). + - **Lists are no longer echoed.** `dashboard-filter-field-unknown` and `widget-filter-field-unknown` no longer append the object's whole field list, and `dashboard-filter-field-not-included` no longer lists the dataset's declared `include` paths: the verdict's (or, on a bare dashboard-filter field, the fix line's own) "Did you mean" names the nearest field. The three empty-selection ids still name what the dataset declares, because no "Did you mean" can: at most three names, then `…`, or "it declares no measures yet". + - **One line, one remedy.** The `suppressWarnings` clause leaves every warning's fix line, and with it: how DatasetWidget reads a dataset-bound widget; that a dotted filter target must traverse relationship fields and have its prefix in `include`, and that `a.b` includes `a`; how to vouch for or opt out of an unprovisioned anchor (`dashboard-filter-field-unprovisioned` now prints the shared one-line anchor fix the other anchor ids print); what replaces each refused `chartConfig` key, and the `os migrate meta --from 17` route; which widget families need no dimensions; and why a flat record listing is not an analytics dataset (ADR-0017). + - **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints. Two ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `table-count-only` and `widget-legacy-analytics-shape`. Seven existing entries gain text. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), all 11 fire on a `dashboard` write; the `hint` FROM → TO, measured at the door: + - 422 `issues[].hint`: `widget-legacy-analytics-unrenderable` 320 → 105; `dashboard-filter-field-unknown` 196 / 195 / 290 → 148 / 147 / 137 (an inherited bare field, a re-targeted bare field, a dotted path); `dashboard-filter-field-not-included` 213 → 139; `widget-filter-field-unknown` 220 → 126; + - 2xx `advisories[].hint`: `widget-legacy-analytics-shape` 338 → 112; `dashboard-filter-field-unprovisioned` 550 → 173; `chart-field-unknown` 413 / 294 / 324 → 150 / 124 / 127 (an x-axis, a y-axis, a series); `chart-measures-missing` 240 → 96; `widget-measures-missing` 310 → 134; `chart-dimensions-missing` 297 → 156; `table-count-only` 311 → 136. + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the seven advisories shorten there; Studio renders no 422 issue's `hint`. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- fba3712: fix(lint): `page-field-unknown` judges each of a `record:related_list`'s bindings against its own object, so a `dataSource`-bound list no longer reports its parent-side `relationshipValueField` as missing from the child (#23207) + + Clause-②: no + + - **Child refs** (`columns`, `sort`, `filter`, `relationshipField`, `add.linkField`) resolve against the node-level `dataSource.object` when the list binds its rows that way, else against `properties.objectName`. Before, they resolved only against `properties.objectName`, so a `dataSource`-bound list's child columns went unchecked. + - **The parent ref** (`relationshipValueField`) resolves against the list's `parentDataSource.object` when it declares one, else against the object the list inherits (its container's binding, else the page object). Before, a `dataSource`-bound list checked it against the child, which gave a false `field "…" is not a field on object "CHILD"` warning on a field of the parent. + - **Severity is unchanged.** These findings stay warnings, so default `os validate`, `os lint` and `os build` accept every stack they accepted before. Under `--strict`, which treats warnings as errors, a `dataSource`-bound list with a misspelled child column can now fail a stack that used to pass, because that column was not checked before. To fix it, correct the column name to a field of `dataSource.object`. + - `WalkedComponent` (from `walkPageComponents`) gains an optional `inheritedObject`: the object a component inherits before its own `dataSource.object` / `properties.object` override. `objectName` is unchanged. +- 5547ad4: feat(spec): export the `controlled_by_parent` exactly-one-master predicate, so a producer asks the spec instead of restating ADR-0055 §1 (#23272) + + Clause-②: yes + + `@objectstack/spec/data` now exports the predicate `ObjectSchema` already refuses with: + + - `controlledByParentMasterRefusal(objectName, sharingModel, fields)` answers "does this object satisfy the exactly-one-master rule?". `undefined` means yes, or that the rule does not apply (another sharing model, or no field map). Otherwise it returns the refusal: the parse issue's `path` (`fields` or `sharingModel`), its `message`, the object's `shape` (`several-required-masters`, `several-masters`, `master-not-required`, `required-lookup-only` or `no-master`), and the `master_detail` and required `lookup` fields it declares. Types: `ControlledByParentMasterRefusal`, `ControlledByParentMasterShape`. + - `isControlledByParentMasterField(field)` answers "does this field count as the master?": a `master_detail` field declared `required: true`, and nothing else. The object satisfies the rule when exactly one of its fields counts. + + A producer that defaults an object to `controlled_by_parent` asks `controlledByParentMasterRefusal(name, 'controlled_by_parent', fields) === undefined` with the field map it is about to write. The answer is the parse's: on every shape the v18 upgrade lists, the export and `ObjectSchema` agree on the verdict, the issue path and the message. + + `@objectstack/lint`: `security-controlled-by-parent-no-relation` and `security-controlled-by-parent-ambiguous-relation` count masters with the exported field predicate instead of their own copy. Their findings, paths and texts are unchanged. A `master_detail` that names no `reference` still does not count for the lint. + + No export is removed, and no accepted or refused shape changes. +- c5dd7fc: feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. + + Migration: + + - `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it. + - A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`. + + `os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door. + + The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. + + `@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`. + + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/sdui-parser@18.0.0-next.2 + ## 18.0.0-next.1 ### Major Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index c6b3d9884e7..d2d88c6b587 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "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 814c1bbebab..8272c88406b 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/plugin-mcp-server +## 18.0.0-next.2 + +### Minor Changes + +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- daafae6: fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call + + Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal. + + + + **BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not. + + - **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there. + - **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart. + - **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change. + - **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`. + - **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire. + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both. + - **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered). +- b1cfa3d: fix(mcp): the data tools, `list_actions` and the record resource answer a coded refusal with its `code` (and `status`) beside the message, on stdio and Streamable HTTP alike (#23509) + + Clause-②: yes (widening) + + **What was wrong.** When a producer refused with an ADR-0112 envelope (`code`, `status`, the message), these surfaces handed an MCP client the message alone. The code is the half a client branches on. The refusals affected: + + - a key whose owner is held: `MFA_REQUIRED` / 403, refused "with the same code" at every seam; + - `RECORD_NOT_FOUND` / 404; + - `PERMISSION_DENIED` / 403; + - `INVALID_FIELD` / 400; + - the exposure refusals `OBJECT_API_DISABLED` / 404 and `OBJECT_API_METHOD_NOT_ALLOWED` / 405. + + `run_action` and `resume_run` already rendered the code. The other tools did not, and neither did the record resource. + + **What a client reads now.** The tools render the shape `run_action` already answers with. The record resource adds the code beside its unchanged `error` sentence, in the `{ error, code, status }` vocabulary the sibling resources `objectstack://objects` and `objectstack://objects/{objectName}` already use. A refusal's message is unchanged on both. + + A refusal's `details` is not rendered on these surfaces. What `details` may disclose is decided per error class at the HTTP boundary. For example, a `PERMISSION_DENIED` from the security gate carries the caller's positions and permission sets, and REST withholds them. This package holds no copy of that policy. + + ## FROM → TO + + | surface | refusal | FROM | TO | + |:--|:--|:--|:--| + | tools `list_objects`, `describe_object`, `validate_expression`, `query_records`, `aggregate_records`, `get_record`, `create_record`, `update_record`, `delete_record`, `list_actions` | coded | `isError: true`; the text is the message | `isError: true`; the text is the JSON `{ "error": { "code", "message", "status" } }`. `status` appears only when the refusal carries one. | + | resource `objectstack://objects/{objectName}/records/{recordId}` (stdio) | coded | `{ "error": MESSAGE }` | `{ "error": MESSAGE, "code", "status" }`. `status` appears only when the refusal carries one. `error` stays the same string. | + | either | no code (a plain `Error`, a thrown string, an empty or non-string `code`) | the message | unchanged, byte-identical | + + **Reading it.** For a tool error, parse the text. If it is an object with `error.code`, branch on the code and show `error.message`. Otherwise the text is the message. For the record resource, `typeof body.error === 'string'` still tells a refusal from a row, and `body.code` is present when the refusal carried one. + +### Patch Changes + +- 0fb55fc: fix(mcp): `run_action` and `resume_run` withhold a `PERMISSION_DENIED` refusal's `details`, as the data tools already do (#23529) + + **What was wrong.** The two action tools render a thrown coded refusal through `errorResultFromThrown`, which served the refusal's `details` verbatim. The security gate's `PERMISSION_DENIED` carries the caller's own positions and permission-set names in `details`. Those are server-side diagnostics. The runtime dispatcher and the REST actions door withhold them, and the data tools and the record resource never serve a refusal's `details`. The action tools were the one place on the MCP surface that still served them, and `executeDeclarativeUpdateAction` writes as the caller, so the gate's write-side refusal reaches that renderer. + + **What a client reads now.** A `PERMISSION_DENIED` on `run_action` or `resume_run` answers the shape the data tools answer: `isError: true`, and the text is the JSON `{ "error": { "code", "message", "status" } }` with no `details`. The code, the message and the status are unchanged. + + Every other coded refusal on the two tools is unchanged. `ACTION_CONFIRMATION_REQUIRED` keeps the `details` that name the action and the member to set, and `resume_run`'s other refusals keep theirs. A throw with no code still answers its bare message. +- 4c21e57: fix(mcp): an MCP tool call that repeats a unique value answers the wire code `UNIQUE_VIOLATION`, not the engine's in-process `DUPLICATE_RECORD` (#23701) + + **Wire change in the MCP tool result: `DUPLICATE_RECORD` becomes `UNIQUE_VIOLATION`.** It applies to every tool error rendered from a thrown refusal, on both transports (Streamable HTTP and stdio), and to the record resource. Measured: `create_record` and `update_record` writing a value a unique field already holds, and `run_action` for an action whose handler write or declarative `operation: 'update'` repeats one. `resume_run` renders through the same writer. An MCP client, AI agents included, that matched `error.code === 'DUPLICATE_RECORD'` on that tool error must match `error.code === 'UNIQUE_VIOLATION'` instead. That is what the REST server, the batch and import rows, both Hono doors and the runtime dispatcher already answer for the same refusal. + + Why: the maintainer ruled one wire spelling for a unique-constraint refusal on every route, `UNIQUE_VIOLATION` (recorded in commit 65846bc46). The MCP tool writer still relayed the engine's spelling. + + - FROM `{"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …","status":409}}`, with `isError: true` + - TO `{"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","status":409}}`, with `isError: true` + + Only the code moves. The result is still an `isError` tool result, `status` stays `409`, and `message` is unchanged. The envelope's keys stay `code`, `message` and `status`: this envelope has no context slot, so the `developerMessage`, `object` and `field` that the HTTP doors carry in `error.details` are not added here. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A hook or action handler that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. Other coded refusals (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`) are unchanged. + + Clause-②: no +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index f367b159a47..8c36962cfd2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)", "type": "module", diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md index 335354ddb7a..d63016d07ae 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,122 @@ # @objectstack/metadata-core +## 18.0.0-next.2 + +### Minor Changes + +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- c2bb302: fix(cli): `os migrate organization-ownership` reads its SQL dialect once, through the driver resolver it shares with metadata-core, and refuses an unresolved dialect before any statement, naming the driver, with no SQLite default left + + Clause-②: yes (widening) + + **Who this is for.** An operator who runs the organization-ownership ceremony (plan or `--apply`) on a PostgreSQL or MySQL database whose driver's knex `config.client` is a client CLASS (knex's bring-your-own-dialect hatch) or a client string the ceremony does not recognise. + + **FROM.** Both arms took the dialect from `String(config.client)` alone, with a reader of their own. + + - Such a run was refused (`driver-unsupported`, exit 1) by the plan step, which runs first. A client class was stringified, so the refusal printed the class's source text in place of the driver's name. + - The apply step and its template lookup each carried a `'sqlite'` default for an unresolved dialect. Neither was reachable, only because the plan step had already refused. + + **TO.** The dialect is resolved once, from the stack's driver, before either arm runs a statement. The resolver is the one metadata-core's legacy-row reader uses: the driver's `dialectName` first, then a recognised `config.client` string. + + - Refused: a client class with no recognised `dialectName`, an unrecognised client string, `dialectName: 'unknown'` (what `SqlDriver` answers for either), and a driver with no dialect at all. Each gets `driver-unsupported`, exit 1, with no statement run and no plan file written. The refusal names the driver: its name, its `dialectName`, and its client string or client class name. + - No `'sqlite'` default remains. The plan and the apply take the resolved dialect and never re-derive it. + - The plan file's `client` field records the client only when it is a string. + + Measured on each in-repo SQL driver: `SqlDriver` on better-sqlite3, pg and mysql2, and `TursoDriver`, resolve to the dialect they did before. + + **metadata-core.** `dialectOfDriver(driver)` is exported. It is the one resolver behind `readLegacyOrganizationRows`, and answers: + + - `'sqlite' | 'postgres' | 'mysql'`; + - `'none'` for a driver that is not a SQL driver; + - `'unresolved'` otherwise. + + A `dialectName` string is the whole answer, recognised or not. A `config.client` is read only when there is no `dialectName`, and only as a string. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index d5a9c832a38..20303fafd87 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).", "type": "module", diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md index 2c19b03007d..00dd256bc41 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/metadata-fs +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [dd29bbe] +- Updated dependencies [c2bb302] + - @objectstack/metadata-core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 1d1c891cc95..07386f3065b 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).", "type": "module", diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md index 613b2486625..d84c3e80636 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,550 @@ # @objectstack/metadata-protocol +## 18.0.0-next.2 + +### Minor Changes + +- 87d0619: feat(spec): the app item read answers by package id. `GET /meta/app/` returns the package's app, or the same 403 / 404 the read by name gives (ADR-0048 §3.1) + + Clause-②: yes (widening) + + ADR-0048 §3.1 makes an installed package's id the route key of its UI. With one app per package the app is the package container, so the console opens an app at `/apps/`. The app item read now accepts that key too. + + - **`GetMetaItemRequestSchema.name`** declares it. For `type: 'app'` only, a key in the package-id grammar (`MANIFEST_ID_PATTERN`, at least one dot) names the package. App names are snake_case with no dot, so the two grammars never overlap and every read by name is unchanged. The new export `isAppPackageIdKey(type, name)` is the one test for the arm. + - **`getMetaItem`** (`@objectstack/metadata-protocol`) resolves it to the package's one app, as the package-scoped list (`GET /meta/app?package=`) serves it, read by its own name in that package. The response's `name` is the app's name. Both hosts' item reads (`RestServer` and the `@objectstack/hono` catch-all) answer it with no change of their own, and the per-caller read gate judges the app exactly as it does by name: + - a caller the app is withheld from gets `403 PERMISSION_DENIED`; + - an unknown package, a package with no app, a package whose app is unpublished, and a package holding more than one app all get the `404` absence answer, byte-identical to the one for an unknown app name. A package holding more than one app (ADR-0019 D3 allows at most one) is also reported once in the server log, naming its apps; + - a caller who holds the app is served it. + - A `packageId` (`?package=`) naming a different package than the key is refused with `400 INVALID_REQUEST`. + - `/meta/app//history`, `/audit` and `/diff` stay by name (`@objectstack/rest`): the package-id key belongs to the plain item read only. + + From a client: `client.meta.getItem('app', 'com.acme.crm')`. +- b1e9280: feat(spec): the seed loader reports the rows each dataset landed on, and the seed-settlement contract hands them to readers + + - **`SeedLoadResult.landedIds`** (`@objectstack/spec/data`, default `[]`): the ids of the stored rows a dataset's records landed on — a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none. A replay therefore reports the rows an earlier load wrote, so the answer holds across boots. The loader's own key and reference resolution is behind it: a dataset keyed on a reference reports the matched row, whose stored key is the resolved id, never the dataset's value. + - **`ISeedSettlementService.seededRecordIds?(object, organizationId?)`** (`@objectstack/spec/contracts`, optional): "which rows of this object did this process's seed write?". Without `organizationId` it answers for the boot-time seed sources (the inline seed `app:seeded` announces); a source publishes its rows before it settles, so a reader inside `app:seeded` already sees them. With `organizationId` it answers for that organization's per-organization replay; the host keeps the most recent replay only, so a process that creates many organizations does not hold every replay's rows. A host that does not implement it publishes no reading, and a reader treats every unowned row as one it cannot place. + - **`@objectstack/metadata-protocol`**: `SeedLoaderService.load()` fills `landedIds` on every per-dataset result. + - **`@objectstack/runtime`**: the `seed-settlement` service implements `seededRecordIds`. `AppPlugin` publishes the inline seed's rows before the source settles (the loader path and the basic-insert fallback alike), and its per-organization `seed-replayer` records each replay's rows under that organization. `SeedSourceHandle` gains `publishLanded(report)`. + + Nothing is removed or renamed. The reader this exists for is the seed-ownership claim, which now hands over only the seed's rows (see the `@objectstack/plugin-security` and `@objectstack/organizations` entries). +- 1baf976: fix(metadata-protocol)!: a metadata delete with `dropStorage` drops an object's table only with the object's last stored row, and is refused `409 RESOURCE_CONFLICT` while another row of the object remains (ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `sys_metadata` row is keyed by its package (ADR-0048), so one object can hold one row per package, and a package-less row beside them. Every such row serves the object over the one table its name maps to. + + **FROM.** `DELETE /api/v1/meta/object/my_object?package=com.acme.a&dropStorage=true` (`deleteMetaItem({ type: 'object', name, packageId, dropStorage: true })` in process), of an object a second package `com.acme.b` also holds, removed `com.acme.a`'s row, answered `200` with a receipt saying `com.acme.b`'s row remains, and dropped the object's table. The object stayed defined and served by `com.acme.b`'s row while its records were gone: its next data read answered `500`. The same held for a package-less row left beside the deleted package's row, and for an uninstall (`deletePackage`, which deletes each of the package's rows with `dropStorage` unless `keepData` is set). + + **TO.** Such a delete is refused `409 RESOURCE_CONFLICT` before anything is written: both rows, the table and its records stay. The message names the rows that remain and the way out. An uninstall of a package holding such an object, without `keepData`, is refused whole, `409 RESOURCE_CONFLICT`, before anything is removed; its message names the object, the rows that keep it, and the ways out. + + **Unchanged.** The delete of an object's last stored row with `dropStorage` still drops its table. A delete without `dropStorage` still removes the named package's row and keeps the table, and its receipt names the rows that remain. Only `active` rows count: a draft is never materialised, so a remaining draft does not keep the table. + + **Remedy.** To remove one package's row of an object another row still holds, send the delete without `dropStorage`. To drop the table, delete the object's other rows first, then send the last row's delete with `dropStorage`. +- 222293f: fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused before any write, as a code-owned object's is (ADR-0029 D9.9) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + An object has exactly one registry entry, so it has exactly one owning package (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). The save door enforced that against a CODE-shipped owner only. Against an object that a package's `sys_metadata` row created at runtime, a save naming a second package was stored, and the registry's single-owner rule then refused it into a warning. The save answered `200`, the data plane kept the first package's schema, and `sys_metadata` held a second-package row that nothing served. + + `saveMetaItem` now refuses that save before any write, with the refusal and the prescription that the code-owner case gives. + + What changes for you, where package `P`'s runtime-authored row owns object `NAME` and `Q` is any other package: + + - FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q", with a `Q`-bound row stored while the registry kept `P`'s schema and logged `registerObject failed … already owned by package "P"`, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing stored for `Q`: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …" + - FROM the same save with `?mode=draft` storing a `Q`-bound draft row, TO the same `422` with no draft row. + - FROM `duplicatePackage` from `P` to `Q` listing in `copied` an object whose name does not carry `P`'s namespace prefix (so the copy keeps `NAME`), with a `Q`-bound row stored that the registry never held, TO that object listed in `failed` with the refusal's text and nothing stored for it. + - The same save naming `P`, or naming no package, answers `200` as before. + + **Remedy, in one line.** Save the object with `?package=P` (or with no package, which addresses `P`'s row), or give `Q`'s object its own name. A `Q`-bound row stored before this change is removed with `DELETE /api/v1/meta/object/NAME?package=Q`. + + **Also changed, in a log line only.** At boot, `loadMetaFromDb` still counts a stored second-package row in its per-record `errors` and leaves `P` registered. Its `Failed to hydrate object/NAME` warning now carries the D9.9 refusal's text and `code=OBJECT_OVERLAY_PACKAGE_MISMATCH`, in place of the registry's ownership message and `code=OBJECT_OWNERSHIP_CONFLICT`. + + **Unchanged.** + + - A code-shipped owner. The package door still answers first (`403 NOT_OVERRIDABLE`). + - Every type except `object`. Two packages may still hold one name. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- 7cb33cf: fix(metadata-protocol)!: an uninstall without `keepData` of a package holding an object another stored row also holds is refused `409 RESOURCE_CONFLICT` before anything is removed, instead of dropping the object's table while the other row still serves it (ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `sys_metadata` row is keyed by its package (ADR-0048), so one object can hold one row per package, and a package-less row beside them, all served over the one table its name maps to. An uninstall drops each of its objects' tables unless `keepData` is set, and a table is dropped only with the object's last stored row. + + **FROM.** `DELETE /api/v1/packages/com.acme.a` (`deletePackage({ packageId: 'com.acme.a' })` in process), for a package whose object a second package `com.acme.b` (or the package-less row) also holds, removed the package and its rows and dropped the shared object's table along with the package's row of it. The object stayed defined and served by `com.acme.b`'s row while its records were gone: its next data read answered `500`. + + **TO.** Such an uninstall is refused `409 RESOURCE_CONFLICT` before its first durable step: the `sys_packages` row, the registry entry, every row, the tables and their records all stay. The message names each shared object and the rows that keep it, and the ways out. + + **Unchanged.** An uninstall with `keepData` drops no table, so a shared object does not refuse it. An uninstall whose objects no other row holds still drops their tables. + + **Remedy.** Uninstall with `?keepData=true` (`keepData: true` in process) to keep every table of the package; or first delete the package's own row of each shared object without `dropStorage` (`DELETE /api/v1/meta/object/NAME?package=com.acme.a`), which keeps that table for the other rows, then uninstall again to drop the tables the package alone held. +- 57994c3: fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft a draft save that names no package writes, and are refused `409 RESOURCE_CONFLICT` where that save is; the draft save's receipt names the package it inherits (ADR-0048, ADR-0070) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold a draft per package. A draft save that names no package (`PUT /api/v1/meta/TYPE/NAME?mode=draft`) writes the draft of the package the item's active rows are bound to, and the strict draft read and the draft preview that name no package serve that draft. The other two draft doors did not take that address. + + **FROM.** Over an item whose active rows are bound to package `P`: + + - With a package-unbound draft stored beside `P`'s draft, `POST /api/v1/meta/TYPE/NAME/publish` promoted whichever draft the database returned first. On SQLite that is the package-unbound one, and it was promoted into a package-unbound active row that no read naming no package serves. The publish answered `200`, and the item read kept serving `P`'s row unchanged. + - With the only draft bound to another package `Q` (saved with `?mode=draft&package=Q`), the strict draft read answered `404 NO_DRAFT`, but the publish promoted `Q`'s draft into a second package's active row. After that, every save and delete of the item that names no package was refused `409`. `DELETE …?state=draft` removed `Q`'s draft. + - With only a package-unbound draft, the publish promoted it into a package-unbound active row beside `P`'s, which no read naming no package serves. + - With drafts bound to two or more packages, `DELETE …?state=draft` was refused `409 RESOURCE_CONFLICT`. The discard took its address from the draft rows, not from the active rows the draft save inherits from. An item never published into a package, with a package-unbound draft beside two or more packages' drafts, was refused the same way. + + Over an item whose active rows two packages hold, the publish naming no package promoted the first draft it met, and the discard removed the draft of the one package the drafts were bound to, while the draft save naming no package was refused `409`. A draft save naming no package that inherited `P` answered "Saved … (env-wide, state=draft)", without the package. + + **TO.** A per-item publish and a draft discard that name no package act on the draft the draft save naming no package writes: + + - **Active rows bound to one package `P`.** They act on `P`'s draft, else the package-unbound draft. A publish promotes either one into `P`'s active row, the row the read naming no package serves next. A package-unbound draft is adopted into `P`, as the draft save adopts it. When neither exists, the publish answers `404 NO_DRAFT`, as the strict draft read does. Its message names the other packages' drafts and the remedy, `?package=`. The discard removes nothing (`reset: false`) and its receipt names the drafts that remain. + - **Active rows bound to two or more packages.** Both are refused `409 RESOURCE_CONFLICT` and change nothing, as the draft save naming no package is. The message names the packages and the remedy, `?package=`. + - **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts. + + The draft save's receipt names the package the draft went into: "Saved view 'my_view' (env-wide, package=com.acme.app, state=draft)". A draft save into the package-unbound row reads as before. + + **Unchanged.** A publish or a discard that names its package (`?package=`, `packageId` in process). A publish stating `packageId: null` in process, which still pins the package-unbound draft. The batch publish and discard of a package's drafts (`POST /packages/:id/publish-drafts`, the discard of a package's drafts). No stored row is rewritten or removed by the upgrade. + + **Remedy.** For an item two or more packages hold, or to publish or discard a draft bound to a package other than the item's own, send the request with `?package=` naming that package (`packageId` in process). A client that compares draft-save receipt text should expect `, package=` after `env-wide` when the draft inherits a package. + + `@objectstack/spec`: the `PublishMetaItemRequestSchema.packageId` description now states what a publish naming no package promotes, where it said "match any package"; the `DeleteMetaItemRequestSchema.packageId` description states what a draft discard naming no package removes. +- a869ea6: fix(metadata-protocol)!: a save naming a package of a runtime object whose owner is its package-less definition is refused before any write, with a way out that moves the object into that package (ADR-0029 D9.9) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + An object has exactly one registry entry, so it has exactly one owner (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). A runtime object first saved naming no package is owned by that package-less row. A save naming package `Q` beside it was written and answered `200`, but the registry refused `Q`'s row into a warning and kept the package-less definition. The metadata read then served `Q`'s row while the data plane served the package-less one, before and after a boot, and at boot `Q`'s row was counted in `errors`. The package-less state is no authoring destination (ADR-0070), so the refusal does not say "bind it to `sys_metadata`". It names the way into `Q` that is true in the state it answers. + + What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any package. Every refusal below is `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written. The error carries `packageId: Q` and `ownerPackageId: null`. + + - With no package's row stored beside the package-less one: FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q" with a `Q`-bound row stored that nothing served, TO the refusal: "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage, which keeps its table and records … then send this save again. …". + - FROM the same save as a draft (`PUT /api/v1/meta/object/NAME?package=Q&mode=draft`, Studio's designer) answering `200` with a `Q` draft that could never go live, TO the refusal: "Move object 'NAME' into package 'Q' first: … then save it naming 'Q' in publish mode, which serves it at once, and draft after that. …". + - Where rows bound to packages are already stored beside the package-less one (the rows this defect wrote before the refusal existed, bound to `Q` or to any other package `P`), every refusal names each such row's own `DELETE …?package=` before the package-less `DELETE`. A `DELETE` naming no package over those rows does not reach the package-less definition: over one package's row it removes that row (its receipt says "(package=P), and it still exists"), and over two it is refused `409 RESOURCE_CONFLICT` ("Name the package this delete is for …"). Two saves change: + - FROM `PUT /api/v1/meta/object/NAME?package=Q` (publish or draft) answering `200`, TO the refusal: "Object 'NAME' is served by its package-less definition; its definitions bound to 'P', 'Q' are never served. To move it into 'Q': DELETE /api/v1/meta/object/NAME?package=P, then ?package=Q, then the same DELETE with no package and no dropStorage (the table and records stay), then save it naming 'Q' in publish mode. …" (with one row, its own package only). + - FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal, where one package's row is stored. The door addresses a save naming no package to the item's one package binding, so the edit was written to that row and never served. Over two bindings this save is refused `409` before, as now. The refusal reads: "A save naming no package of object 'NAME' is addressed to its stored definition bound to 'Q', which is never served: … Delete the 'Q' row first (REST: DELETE /api/v1/meta/object/NAME?package=Q), then send this save again: it then saves the package-less definition. …". + - FROM promoting `Q`'s draft of the object (`POST /api/v1/meta/object/NAME/publish?package=Q`, or `POST /api/v1/packages/Q/publish-drafts`) answering success with `Q`'s active row stored beside the package-less one and never served, TO the refusal. This reaches a draft stored before the package-less row existed, or one a draft save naming no package wrote over the rows above. The per-item publish answers `422`; `publish-drafts` answers `200` with `outcome: 'refused'` and the draft in `failed[]` with this code (ADR-0067 D2). The text reads "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage … then POST /api/v1/meta/object/NAME/publish?package=Q: the draft then serves the object at once. …". Where any package's active row is also stored, it names deleting each such row first, as above, then the same `POST …/publish?package=Q`. The publish step always names the draft's package: a publish naming none resolves by the rows the deletes leave, and over `[package-less, P]` with both `P`'s draft and a draft bound to no package it would then promote the other draft. The draft stays stored. + - A publish naming NO package of a draft bound to no package, over one package's row `P`, is addressed into `P` (the draft doors adopt the unbound draft into the one binding), and is refused the same way. No publish moves that draft into `P`: with `P`'s row deleted it lands in the package-less definition, and a publish naming `P` answers `404 NO_DRAFT`. So its text says that: "This draft of object 'NAME' is bound to no package; a publish naming no package is addressed into 'P', whose row is never served. DELETE /api/v1/meta/object/NAME?package=P, then publish again: the draft lands in the package-less definition. To move it into 'P' then: the same DELETE with no package and no dropStorage (records stay), then save it naming 'P' in publish mode. …". + - The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts each package's row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, naming every package's row, and ends "The row bound to 'Q' is not registered.". It carried the registry's "already owned by package \"sys_metadata\"" text. + + **Remedy, in one line per state.** Do not send the request again unchanged; follow its order. With no package's row stored: `DELETE /api/v1/meta/object/NAME` (no `package`, no `dropStorage`), then `PUT /api/v1/meta/object/NAME?package=Q` in publish mode. That publish-mode save makes `Q`'s row the owner at once and at every boot; a draft save registers nothing, so draft only after it. With rows bound to packages stored (`Q` or any other): first `DELETE /api/v1/meta/object/NAME?package=PKG` for each such package, then as above. Where the one stored row is the one a save naming no package was addressed to, after its delete that save may instead be sent again, and it edits the package-less definition. For a refused publish of `Q`'s draft (whether the request named `Q` or the address resolved to it): the same deletes for its state, then `POST /api/v1/meta/object/NAME/publish?package=Q`; the draft's promotion makes `Q`'s row the owner. For a refused publish naming no package of a draft bound to no package: `DELETE …?package=P`, then the same publish (the draft lands package-less), then the package-less `DELETE` and a publish-mode save naming `P` if the object belongs in `P`. Never rely on a `DELETE` naming no package while a package's row is stored: it removes that row, or is refused over two. No step drops a table or a record. + + **Unchanged.** + + - A save naming no package where only the package-less row is stored (its own re-save), in either mode. + - The D9.9 refusal for an owner bound to a real package: same code, status and prescription. + - An object with no owner, and an object a code package ships. + - Every type except `object`. + - Stored rows are not rewritten on upgrade. +- 4feb2d0: fix(metadata-protocol): every write over a sealed, package-held name reads one sentence that states what the door knows; the inferred create remedy retires + + Clause-②: no + + + + **BREAKING** (a public method-signature narrowing in `@objectstack/metadata-protocol`), shipped as `minor` under the launch-window convention for breaking changes. The `'create'` member of `ObjectStackProtocolImplementation.packagedBaseRefusal`'s `operation` (now `'save' | 'delete'`) and the optional `operation` parameter of `tenantAuthoredWriteRefusal` retire. A caller that passed `operation: 'create'` passes `'save'` (or omits `operation` on `tenantAuthoredWriteRefusal`): the verdict, the code and the status were always the same. + + **What was wrong.** A save over a name a managed package holds, on a type with no environment overlay, is refused `403 NOT_OVERRIDABLE`. The sentence guessed what the save meant from `If-None-Match: *`. A publish-mode `*` was told "… so its name is taken. To author your own TYPE, choose a name no package or built-in holds …", and every other save, the console's generic create (`?mode=draft` with `If-None-Match: *`) included, was told "… Edit the source artifact and redeploy." Neither request declares a create or an edit, so each remedy was wrong for one of its callers. An editor who followed the first authored a second item. A creator who followed the second, and every administrator of an installed package, was sent to an artifact they cannot edit. + + **What changed.** + + - `@objectstack/metadata-protocol`: every write over such a name, on a type with no regime row, reads one sentence, whatever its mode or pin: "'TYPE/NAME' is provided by a managed package and is sealed: it cannot be written here. GET /api/v1/meta/TYPE lists the names in use." The same sentence comes from the repository's type door (draft promotion, restore, revert). It gives no create advice and no source-artifact advice. It names no holding package, because neither door has the package at hand. A type with a regime row (`flow`, `action`, `permission`, `datasource`) keeps its regime sentence, and every removal keeps its sentence. + - `@objectstack/metadata-protocol` (retired, no producer remains): the two signature members named under **BREAKING** above, and the `create` arm of the internal `SealedItemOperation`. + - `@objectstack/spec`: the `GetMetaItemResponseSchema.version` and `MetadataConflictErrorSchema.currentVersion` descriptions say that a `null` beside `editable: false` takes no save, and prescribe no `If-None-Match: *` pin for it. + - `@objectstack/runtime`: a permission-set clone (`POST /api/v1/security/permission-sets/:name/clone`) whose save loses the race for the target name now answers the door's own `409` name-taken refusal ("Permission set 'NAME' already exists — a clone must take a NEW machine name. …", code `RESOURCE_CONFLICT`, where it used to answer code `METADATA_CONFLICT`), the same refusal a name the catalog already held reads. It used to relay the `/meta` door's conflict sentence, which told the caller to send `If-Match`, and this door takes no `If-Match`. + + **Unchanged.** Which requests are accepted or refused. The `NOT_OVERRIDABLE` code and `403` status. The named read-only base's `ITEM_LOCKED` sentence. The `/meta` door's conflict sentence for its own callers. +- 954bf72: fix(metadata-protocol)!: a draft of an object promoted into a second package after another package's runtime-authored row came to own the object is refused before any write, as its save is (ADR-0029 D9.9) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + An object has exactly one registry entry, so it has exactly one owning package (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). The save door refuses an `object` row bound to a package that does not own the object. A draft save is judged against the owner of its own moment, so package `Q`'s draft of an object that no row held yet was stored, correctly. When package `P`'s runtime-authored row of that object was saved after it, `P` owned the object. Promoting `Q`'s draft then wrote `Q`'s active row beside `P`'s and answered success, while the registry refused it into a warning and kept serving `P`'s schema. An uninstall of `P` then refused `409 RESOURCE_CONFLICT` until a row was removed by hand. + + Both promotion doors now ask the save door's question of the row the promotion writes, and refuse it before any write, with the save door's code and prescription. + + What changes for you, where package `P`'s runtime-authored row owns object `NAME`, `Q` is any other package, and `Q`'s draft of `NAME` was saved before `P`'s row existed: + + - FROM `POST /api/v1/meta/object/NAME/publish?package=Q` (`publishMetaItem({ type: 'object', name: NAME, packageId: Q })`) answering `200` "Published draft …", with a `Q`-bound active row stored while the registry kept `P`'s schema, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written and `Q`'s draft still stored: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …", the save door's text for the same row. + - FROM `POST /api/v1/packages/Q/publish-drafts` (`publishPackageDrafts({ packageId: Q })`) answering `200` with `outcome: 'published'` and `failed: []`, TO `200` with `success: false`, `outcome: 'refused'` and `publishedCount: 0`. `NAME` is in `failed[]` with `code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH'` and that text. Every other draft of `Q` is in `failed[]` with `code: 'BATCH_ABORTED'`. Nothing is promoted (the batch is all-or-nothing, ADR-0067 D2). + + **Remedy, in one line.** Re-save the draft bound to the owner (`PUT /api/v1/meta/object/NAME?package=P&mode=draft`) and publish it there, or give `Q`'s object its own name, and discard `Q`'s draft with `DELETE /api/v1/meta/object/NAME?package=Q&state=draft`. + + **Unchanged.** + + - A promotion of `Q`'s draft of an object no other package's row holds, and the owner `P`'s promotion of its own draft. + - A publish naming no package that promotes a package-unbound draft into the owner's package. + - A code-packaged owner. An object a code package ships is sealed, so a promotion of another package's draft of it still answers `403 NOT_OVERRIDABLE` (the batch door: that code in `failed[]`), as the save door does, and never `OBJECT_OVERLAY_PACKAGE_MISMATCH`. + - A package-less owner (the `sys_metadata` sentinel). Its rule is decided separately. + - Every type except `object`. Two packages may still hold one name. + - Rows stored before ADR-0029 D9.9 stay as they are. Nothing is rewritten on upgrade. +- c03077e: fix(metadata-protocol)!: a draft save that names no package, of an item never published into a package, writes the draft the publish and the discard naming none act on, and is refused `409 RESOURCE_CONFLICT` where they are; the draft discard's receipt names the package (ADR-0048, ADR-0070) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `sys_metadata` row is keyed by its package (ADR-0048), so an item can hold a draft per package. Over an item never published into a package (no active row bound to one), the per-item publish (`POST /api/v1/meta/TYPE/NAME/publish`) and the draft discard (`DELETE /api/v1/meta/TYPE/NAME?state=draft`) that name no package act on the package-unbound draft, else on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts. The draft save naming no package did not take that address. + + **FROM.** Over such an item, `PUT /api/v1/meta/TYPE/NAME?mode=draft` with no `?package=`: + + - With the only draft bound to package `Q` (saved with `?mode=draft&package=Q`), the save wrote a package-unbound draft beside `Q`'s and answered `200` ("Saved … (env-wide, state=draft)"). The drafts became `[package-unbound, Q]`, and from then on the publish and the discard naming no package acted on the package-unbound one, while `Q`'s draft stayed pending. + - With drafts bound to two or more packages and no package-unbound draft, the save wrote a package-unbound draft beside them and answered `200`, while the publish and the discard naming no package were refused `409`. + - `DELETE …?state=draft` of a package's draft answered "Draft discarded — TYPE/NAME", without the package. + + **TO.** + + - With the only draft bound to `Q`, the save updates `Q`'s draft and its receipt names the package ("Saved view 'my_view' (env-wide, package=Q, state=draft)"). The strict draft read's `version` (`GET …?state=draft`) is that draft's, so a save pinned to it with `If-Match` is accepted; the publish and the discard naming no package act on the same draft. + - With drafts bound to two or more packages and no package-unbound draft, the save is refused `409 RESOURCE_CONFLICT` and writes nothing, as the publish and the discard are. The message names the packages and the remedy, `?package=`. + - A draft discard of a package's draft names the package: "Draft discarded (package=Q) — TYPE/NAME. [seq=N]". The discard of a package-unbound draft reads as before. + + **What follows from the save writing `Q`'s draft**, over an item never published into a package whose only draft is bound to `Q`, for a draft save naming no package: + + - FROM `If-Match` carrying `Q`'s draft token (the version `GET …?state=draft&package=Q` serves) refused `409 METADATA_CONFLICT`, TO accepted: that draft is the row the save writes, so the strict draft read naming no package now serves the same token. + - FROM `If-None-Match: *` (a first-write pin) creating a package-unbound draft and answering `200`, TO `409 METADATA_CONFLICT` with nothing written: `Q`'s draft is the row the save would overwrite. + - FROM a permission set whose body declares `packageId: Q` refused `400 VALIDATION_ERROR` (the owner binding of ADR-0086 D3, judged against a save "into no package"), TO accepted: the binding the write resolves is `Q`. A body declaring another package is still refused, and the refusal now names `Q`. + + `@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states the draft save's address in the words the publish's and the discard's descriptions use: the draft of the one package the item's active rows are bound to, else its package-unbound draft; for an item never published into a package, the package-unbound draft, else the one package's draft; refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages. It said the save wrote "the env-local draft" whenever the active row was not bound to a package. No key, type, enum or default moves. + + **Unchanged.** A draft save that names its package (`?package=`, `packageId` in process). An item with an active row bound to a package, whose draft save naming no package still inherits that package. An item with a package-unbound draft, which a draft save naming no package still updates, whatever packages' drafts sit beside it. A brand-new item with no draft at all, whose first draft save naming no package writes the package-unbound draft. No stored row is rewritten or removed by the upgrade. + + **Remedy.** For an item two or more packages hold drafts of, send the draft save with `?package=` naming the package (`packageId` in process). A client that compares draft-discard receipt text should expect ` (package=ID)` after `Draft discarded` when the draft discarded is bound to a package. +- 069fd40: fix(metadata-protocol): the strict draft read naming no package (`GET /api/v1/meta/TYPE/NAME?state=draft`) serves its content from the draft its `version` names, the draft the draft save, the per-item publish and the draft discard naming no package act on (ADR-0048, ADR-0070) + + Clause-②: yes + + A `sys_metadata` row is keyed by its package (ADR-0048), so an item never published into a package can hold a package-unbound draft beside package `Q`'s. The strict draft read naming no package answered its `version` from the package-unbound draft (the draft the save naming no package writes), but its content from whichever draft row the store returned first. On a store that returned `Q`'s row first, it served `Q`'s body under the package-unbound draft's token, and a save pinned to that token (`If-Match`) wrote the package-unbound draft, a different row from the one read. + + The read now takes its content from the same address as its `version`, in every storage order: the package-unbound draft when one is stored, else the one package's draft; for an item whose active rows are bound to one package, that package's draft as before. The draft-overlay preview naming no package (`previewDrafts`) serves the same draft. Drafts bound to two or more packages with no package-unbound draft are still served best-effort, with `version: null`, as before (the save naming no package is refused there). No accept set changes: no request that was answered `200` is refused now, and `?package=` still reaches each package's draft. + + `@objectstack/spec`: the description of `PublishMetaItemRequestSchema.packageId` now says, as `SaveMetaItemRequestSchema.packageId`'s does, that a publish naming no package is refused for drafts bound to two or more packages only when no package-unbound draft is stored. Description only. + +### Patch Changes + +- 0d3ea29: fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + + Clause-②: no + + Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + + - **`@objectstack/plugin-security`.** + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. + - **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. + - **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. + - **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. + - **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. +- 47d569e: fix(metadata-protocol): a metadata read that names no package serves the row a save that names no package writes, when a package-unbound row of the item is stored beside its one package's row (ADR-0048, ADR-0070) + + Clause-②: no + + - **What was wrong.** A save naming a package (`PUT /api/v1/meta/TYPE/NAME?package=P`) of an item that already had a package-unbound row stored the package's row beside it, with the unbound row first. The read naming no package (`GET /api/v1/meta/TYPE/NAME`) then served the unbound row's body. Meanwhile the save naming no package wrote the package's row, the delete naming no package removed it, the list served it, and the read's own `version` was the package row's token. An author editing the item without `?package=` was served a body that no save changed. A save pinned to the served token overwrote the package's row with an edit of the unbound body. + - **What it does now.** For an item whose stored active rows are bound to exactly one package, a read that names no package takes its stored content from what the read naming that package takes it from: the package's own row, else the package-unbound one. That is the address the package-less save, delete and version token already resolve. The doors whose served row changes: + - the item read, `GET /api/v1/meta/TYPE/NAME`; + - the layered read, `GET …/layers` and the deprecated `?layers=` (its `overlay` and `effective`); + - the published read, `GET …/published`, on the REST server and on the dispatcher; + - the draft reads, `?state=draft` and the draft preview, whose address comes from the same active rows, because the package-less draft save inherits that package. + + An item bound to two or more packages is read as before: the first stored row, ADR-0048 §3.3's best effort. A save or delete naming none of them is still refused `409 RESOURCE_CONFLICT`. The lock reported by these reads is selected as before. + - **What follows the read.** In-process readers of the item that name no package see the same row: the data doors' object reads (import reference targets, export and template fields), seed apply's object lookup, and the security catalog echo of a `permission` or `position`. + - **The `object` save's destructive-change check (`409 DESTRUCTIVE_CHANGE`) now judges the row the save overwrites**, read at the save's own address. Before, it read naming no package and judged the first stored row, which is the package-unbound row in the order that saves produce it. In this two-row state that moves its verdict in both directions: + - A package-less save that drops a field only the package's row has is now refused. Before, it was judged against the package-unbound row and accepted, and the field was dropped with no confirmation. + - A package-less save that drops a field only the package-unbound row has is now accepted. Before, it was refused over a row it does not write. + - A save naming a package with no row of its own is judged against the package-unbound row it overlays, never another package's row. + + The rule and its remedy (`force`) are unchanged. + - **The draft read's corner: a 200 becomes a 404.** Take an item whose active rows are bound to package P and whose only draft is bound to another package Q. Naming no package, the strict draft read (`?state=draft`) now answers `404 NO_DRAFT` instead of serving Q's draft. P's draft is what a package-less draft save would write, and there is none; the read's `version` was already `null`. Read that draft with `?package=Q`. The draft preview naming no package now serves P's active row. + - **What does not change.** No stored row is rewritten or removed. No key, export, schema or error code moves. No metadata write door's own rule moves. The package-unbound row still stands in for every package that has no row of its own: a read naming such a package serves it. Once the package's row is deleted, it is what the read and the next package-less save address again. +- 3b28905: fix(metadata-protocol): a refused first-write pin is told what it asserted + + Clause-②: no + + **What was wrong.** A save that sent `If-None-Match: *` (the first-write pin) to `PUT /api/v1/meta/:type/:name` over a row that already exists was refused `409 METADATA_CONFLICT` with "TYPE/NAME has been modified since you loaded it. Expected parent null but current is VERSION.", though the caller had loaded nothing. + + **What changed.** + + - Such a save is now told what it asserted: "TYPE/NAME: this save asserted that no draft row exists (the first-write pin, If-None-Match: * on the REST door), but one does (current is VERSION). …" (`active row` for a save without `?mode=draft`). Every other conflict keeps its sentence. + + **Unchanged.** Which requests are accepted or refused, every code and status, and the conflict body's `currentVersion` and the refusal's keyed fields. +- 5fd2c76: feat(spec)!: seven overdue ADR-0087 D2 conversions leave the load path at 18 — six flow-node config aliases and a sharing rule's `accessLevel: 'full'` are refused with their rename at every door that judges a flow or a sharing rule + + Clause-②: no (narrowing) + + **BREAKING (authoring):** seven ADR-0087 D2 conversions whose one-major window had already run are now retired from the load path: `flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-map-flow-alias`, `flow-node-subflow-flow-alias`, `flow-node-script-config-aliases` and `sharing-rule-access-level-full-to-edit` (protocol 17's window), and `flow-node-crud-filter-alias` (protocol 11's). The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites these spellings. An author who still writes one meets the refusal of the surface's own contract, and every one of those refusals is now the upgrade prescription: it names the canonical key and the release that refuses the old spelling. The flow-node prescriptions used to say "the conversion rewrites it at load". `accessLevel: 'full'` used to get zod's bare "Expected one of: read, edit"; `SharingLevel` now names `edit`. + + Migration: + + - CRUD node (`get_record` / `create_record` / `update_record` / `delete_record`) `config.object` → `config.objectName`, and `config.filters` → `config.filter`. Rename the key; the value is unchanged. + - `notify` node `config.to` → `recipients`, `config.subject` → `title`, `config.body` → `message`, `config.url` → `actionUrl`, and a nested `config.source: { object, id }` → the flat pair `sourceObject` / `sourceId`. Rename (or move) the key; the value is unchanged. + - `map` and `subflow` node `config.flow` → `config.flowName`. Rename the key; the value is unchanged. + - `script` node `config.functionName` → `config.function`, and `config.input` → `config.inputs`. Rename the key; the value is unchanged. (`connectorConfig.input` on a `connector_action` node is canonical; leave it.) + - Sharing rule `accessLevel: 'full'` → `accessLevel: 'edit'`. `full` never granted more than `edit`. + - If a config carries both spellings with different values, decide which one you mean, keep it under the canonical key, and delete the old one. + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. (`config.filters` belongs to protocol 11, below the migration floor, so `os migrate meta` does not list it: rename that one by hand.) + + Stored metadata needs no edit. The automation engine's flow rehydration and stored-row rehydration still replay the whole conversion chain, these seven entries included, so `sys_metadata` flows and sharing-rule rows written under an old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door. + + The metadata save door's flow fallback now reaches the canonicalizer's verdict for every retired flow shape. When no automation service is reachable (or its canonicalizer throws), `saveMetaItem` judges a flow body after replaying the full conversion chain the automation engine's canonicalizer replays, retired entries included (the default flip `flow-decision-mode-inclusive-explicit` excepted, as there). That covers the seven entries retired here and those retired earlier that walk a flow: `flow-inert-keys-removed` (`flow.active`, `flow.template`, `nodes[].outputSchema`, `errorHandling.fallbackNodeId`) and `flow-node-wait-timeout-keys-removed` (`waitEventConfig.timeoutMs` / `onTimeout`). On such a host a body carrying one of those older keys was refused 422 by its tombstone before; it is now healed for the verdict, which is what a host with an automation service already did. What is stored stays the body as submitted, so a host's verdict on a saved flow no longer depends on whether it runs an automation service. + + Eight more entries below 18 stay on the load path, each with the reason recorded in its docblock: `flow-node-http-callout-rename` and `page-kind-jsx-to-html` (the old value is still accepted by its schema), `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (the schema still declares and folds the deprecated key), `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` (the node's `config` has no contract to refuse the loose keys), `datasource-driver-mongo-to-mongodb` and `retry-policy-converged`. + + +- 56147a8: fix(metadata-protocol): `duplicatePackage` copies an object whose name lacks the source namespace prefix into the target namespace, and a copied reference follows it + + Clause-②: no + + `duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace, because an object's name is its registry key and its table name, so a same-name copy is a second claim on the source's object. + + **FROM.** Only a name that starts with the source prefix was renamed (`crm_account` → `crm2_account`). An object whose name lacks it (`ticket`, stored by a direct publish-mode save, which no prefix gate reads, or bound to a package that had no namespace) kept its name. The copy then failed: it was listed in `failed[]` with the object-ownership refusal ("bind the customization to 'com.acme.crm', or have 'com.acme.crm2' extend the object instead"), which names no way out for a duplicate. Before that refusal existed, the same object was listed in `copied` while the registry refused the target row, and the copy sat over the source's table. Every copied item that referenced it (a lookup `reference`, a view's `object`, a flow node's `objectName`) still named the source's object. + + **TO.** Such an object is copied as `_` (`ticket` → `crm2_ticket`), the rename the namespace-prefix rule itself prescribes (ADR-0129 D2), with its own registry entry and its own table. Every reference to it is rewritten where a property names an object, and only when the value is exactly the object's name. A field that happens to be called `ticket`, and the word in a label or description, are left unchanged. When that target name is already taken (the source also holds `crm_ticket`, whose copy is `crm2_ticket`, or the source holds an object of that name), the object is listed in `failed[]` before anything is written for it. The entry names the object, the name it would take and who holds that name, and says to rename the object on the source. + + **Also.** A map keyed by object name (a permission set's `objects`, a translation's `objects`) now has its keys follow the copied objects, prefixed names included. So the duplicate's permission set grants on the duplicate's objects. Before, it kept the source's names and granted on the source. + + **Unchanged.** A prefixed object's copy name, a non-object item's name, and every other `failed[]` cause are unchanged. +- 7b64a3d: fix(metadata-protocol): `duplicatePackage` points a copied permission set's field-level rules at the copied objects + + Clause-②: no + + `duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace and rewrites the references to it. A permission set's field-level rules are a map keyed `.` (`PermissionSetSchema.fields`), and the security plugin applies an entry to the object its key names. + + **FROM.** A copied permission set kept the source's `fields` keys: `crm_customer.full_name` stayed `crm_customer.full_name`, and `ticket.title` (an object whose name lacks the source prefix, copied as `crm2_ticket`) stayed `ticket.title`. The copy's `objects` map already named the copied objects, so the copied set granted access to the duplicate's objects while its field rules applied to the source's objects. A field the source set hides or locks was readable and editable on the duplicate's object for whoever held the copied set. + + **TO.** In a copied set's `fields`, a key's object part takes the copied object's name: `crm_customer.full_name` → `crm2_customer.full_name`, `ticket.title` → `crm2_ticket.title`. The object part is everything before the key's first `.`, and it is rewritten only when it is exactly the name of an object the copy renamed. The field part is left as stored, even when it spells an object's name (`ticket.ticket` → `crm2_ticket.ticket`). A key naming an object outside the package keeps its name. If a rewritten key equals a key the set already holds (the source set also has a rule for an object, outside the package, named like the copy), the set is listed in `failed[]` with both keys, and nothing is written for it, because keeping either rule would drop the other. + + **Unchanged.** The source set, the copied set's `objects` map, and every other copied item are unchanged. +- 8226755: fix(metadata-protocol): `duplicatePackage` refuses a copied item whose renamed object reference meets a name the item already holds, instead of merging the two + + Clause-②: no + + `duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace and rewrites each reference to it where a property names an object: a string, an array entry, or a record key (a permission set's `objects`, a translation's `objects`). + + **FROM.** When a copied item also named the object the copy is about to be called, the renamed name landed on a name the item already held, and the copy merged the two with `failed: []`. A permission set holding `objects: { crm_customer: { allowRead: true }, crm2_customer: { allowRead: true, allowEdit: true, allowDelete: true } }`, duplicated into namespace `crm2`, was copied as `objects: { crm2_customer: { allowRead: true, allowEdit: true, allowDelete: true } }`: whichever entry came last was kept, so one grant was dropped, and here the copied object gained edit and delete the source set never granted on its own object. A translation's `objects` dropped one object's labels the same way. A hook whose `object` list held both names was copied as `['crm2_customer', 'crm2_customer']`, so its handler ran twice per event on the copied object. + + **TO.** Such an item is listed in `failed[]`, and nothing of it is written (no item row, no history entry). The entry names the item, the property, the name that moved and the name it met, and the source edit that clears it, for example: "Permission set 'crm_access' is not copied: its `objects` key 'crm_customer' follows the copied object to 'crm2_customer', a key it already holds there, which this copy does not rename. … Remove 'crm_customer' or 'crm2_customer' on the source, then duplicate again." Remove one of the two names on the source item, then duplicate again. + + **Unchanged.** An item with no such collision copies as before, its references following the copied objects. An entry the source item itself repeats is copied as stored. The source package is never modified. +- cdc42eb: fix(metadata-protocol): `duplicatePackage` keeps a copied field's key and `name` in step, and gives a bare object's view item the copied object's `OBJECT.VIEWKEY` name + + Clause-②: no + + `duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace and rewrites the references to it. Three copied names fell out of step with what they name. + + **FROM.** + + - A field named after a prefixed object, or ending in its name, kept its `fields` key but had its `name` renamed. Duplicating `crm` into `crm2`, the field `crm_customer` of `crm_customer` was copied as `fields.crm_customer.name: 'crm2_customer'`, and `x_crm_customer` as `x_crm2_customer`. The data plane addresses the column by the key. The save door's FLS lint reads the `name`, so a copied permission set with a rule on such a field was refused at the copy with `security-fls-unknown-field`. + - A view item of an object whose name lacks the source prefix kept its name. `ticket.board` was copied under that name with `object: 'crm2_ticket'`, so a by-name read naming no package served the source's item. A view item of an object outside the package that carries the source prefix was renamed while its `object` stayed (`crm_ext.board` became `crm2_ext.board`, bound to `crm_ext`). + - A form action's `target` naming such a view item (`ticket.entry`) kept the source's name, so the copy's button opened the source's form. + + **TO.** + + - A field's `name` follows its key, and both are kept: `crm_customer` and `x_crm_customer` keep their names. Free text renames an object's name only as a whole identifier, so `x_crm_customer` stays in a description or a view's columns too. + - A view item's name takes its object's copy name and keeps its view key: `ticket.board` becomes `crm2_ticket.board`. An item of an object the copy does not rename keeps its name. + - A value that is a renamed view item's whole name follows it: the action's `target` becomes `crm2_ticket.entry`. + + **Unchanged.** Object names, object references and a permission set's `objects` and `fields` keys are renamed as before. The source package is never modified. To bring an earlier copy in step, delete it and duplicate the source again. +- 7ffc578: fix(metadata-protocol): an object's "Used by" answer lists a permission set that names the object only in its `fields` keys (#23539) + + **What was wrong.** `findReferencesToMeta` serves `GET /api/v1/meta/object/:name/references`, the admin "Used by" panel, which reads an empty answer as "Safe to delete." `PermissionSetSchema.fields` is keyed `.`, and the security plugin applies a set's field rules to an object by that key prefix. The reference index read `permission.fields` only as a site naming `field` items, never an object. So a set whose only rule for an object was a field rule (`objects: {}`, `fields: { 'customer.full_name': … }`) was missing from that object's answer, and the object could be cleared for deletion while the set still masked its fields. + + **What the answer reads now.** Such a set is listed for the object that each `fields` key starts with, at path `fields{key}` with kind `permission fields`. The object part is the text before the key's first `.`, the same split the security plugin makes. A key whose field part spells another object's name is not a reference to that object. A key with no object part (`.full_name`) names no object. + + Unchanged: every existing row of the answer, the response shape, the `field` target's `501 NOT_IMPLEMENTED` refusal, and `duplicatePackage`. +- c5dd7fc: feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. + + Migration: + + - `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it. + - A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`. + + `os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door. + + The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. + + `@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`. + + +- 7dc7d22: fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read + + Clause-②: no + + `updateData` and `deleteData` judge a caller's version token (`If-Match`, or the body/query `expectedVersion`) on the row their existence probe reads with a system context. On a mismatch they used to answer `409 CONCURRENT_UPDATE` with that system-read row as `currentRecord`, before the caller's row- and field-level security had run. + + Now a mismatch re-reads the row under the caller's own context, and only then answers: + + - **A row the caller cannot read:** `404 RECORD_NOT_FOUND`, the same answer the same request without a version token gets. It carries no `currentVersion` and no `currentRecord`. + - **A row the caller can read:** `409 CONCURRENT_UPDATE` as before, with `currentRecord` read as the caller reads it, so a field hidden from the caller is absent from it. `currentVersion` is unchanged. + + A request whose token matches, or that sends none, is unchanged and issues no extra read. Every door that renders the protocol's answer (`RestServer`, the runtime dispatcher's `/data` domain and the Hono hosts) gets the narrowed answer. +- cad343e: fix(metadata-protocol): deleting a runtime object that was loaded at boot unregisters it at once, instead of leaving it registered and served until a restart + + Clause-②: no + + Before this fix, deleting such an object could leave it registered. This happened on a kernel whose ObjectQL plugin has no `environmentId`: the CLI's host-config boot, or a control plane. The object was a runtime object created before the server's last start. `DELETE /api/v1/meta/object/NAME` answered "it no longer exists" and removed the stored row, but the object stayed registered. Until a restart, data CRUD kept serving its table's records. `GET /api/v1/meta/object/NAME`, the object list and the layered read kept serving the deleted definition. + + The cause: at boot the ObjectQL plugin copies every registered object into the `metadata` service. The delete's registry heal read that copy as the code baseline and registered it again. An object created in the same process was never copied, so its delete already worked. + + Now an object body in the `metadata` service stamped tenant-authored (`_provenance: 'org'`) is never read as the object's code layer. What changes on those kernels: + + - After the delete, the object is unregistered, as one created in the same process is. Data CRUD answers `404 OBJECT_NOT_FOUND`. The by-name read and the object list no longer serve it. Its table and records are kept: a delete without `dropStorage` drops nothing. This holds whether or not the row was bound to a package. + - A save afterwards that names a package makes that package the object's owner at once, over the kept table. + - The layered read's `code` layer for a runtime object is `null`, as it already was on a kernel with an `environmentId`. Before, it was the boot copy. + + Unchanged: an object a code package ships keeps its baseline through the same delete. The `metadata` service itself still holds its boot copy. Other types are not affected: the security catalog's `permission` and `position` entries in the `metadata` service are still read as before. +- cbe7712: fix(metadata-protocol): an orphan adoption moves each adopted object's registry owner and definition to the target package in the same request (ADR-0070 D5) + + Clause-②: no + + `reassignOrphanedMetadata`, the method `POST /api/v1/packages/:id/adopt-orphans` calls, rebinds every package-less `sys_metadata` row to the target package. For an object, only the stored row moved. The registry kept the owner that the package-less row had registered (the `sys_metadata` sentinel) and the pre-adoption definition until the next restart. Until then, a save of the object naming the target package answered success and never went live: its new fields were not on the data plane. + + Each adopted object row now goes through the same registry write-through a save uses, in the same request. The package-less row's own registration leaves the registry first (`SchemaRegistry.unregisterObject`, the verb the delete heal already uses), so the object keeps exactly one owner (ADR-0029 D3). Right after the adoption, the target package owns the object, the data plane serves its definition stamped with that package, and the package's object list includes it. A save naming the target package then goes live at once, and the next boot registers the same owner. + + What stays as before: + + - The adoption's request and response shapes, and which rows it rebinds. + - A package-less row of an object that another package's row owns is rebound in storage only, and the registry keeps that owner. The next boot judges the two rows, as before. + - If another package extends the object, the registry refuses to drop the old owner. The adoption then logs one `error` naming the object, and the registry keeps the old owner until the next restart. + - Views follow their rebound row as before. Other types are rebound in storage only. +- 9bb9370: fix(metadata-protocol): an orphan adoption no longer makes a draft view live; an adopted draft view is rebound in storage only (ADR-0070 D5, ADR-0048) + + Clause-②: no + + `reassignOrphanedMetadata`, the method `POST /api/v1/packages/:id/adopt-orphans` calls, sends each rebound view row through the registry write-through so the registry follows it at once. It admitted a view row whatever its `state`. So adopting a package-less DRAFT view registered its draft body as live, stamped with the target package, until the next restart. The boot registers active rows only, so it dropped the view again. A draft adopted beside a published row of the same view replaced the published body the runtime served. + + Only an active row now reaches the registry, for a view as for an object. An adopted draft view is rebound in storage only: the registry serves nothing for it until it is published, and the next boot agrees. + + What stays as before: + + - Which rows the adoption rebinds, the stored `state` of each, and the response (`reassignedCount`, `reassigned`). + - An adopted active view is served at its new binding at once, and an adopted active object moves its registry owner, as before. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [c911ab6] +- Updated dependencies [87d0619] +- Updated dependencies [fba3712] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [54af6c0] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [30b0fc2] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/lint@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + - @objectstack/sdui-parser@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index c8fa986b2bb..cd729eff52b 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).", "type": "module", diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md index 09f20341506..e4c017ef5b2 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,104 @@ # @objectstack/metadata +## 18.0.0-next.2 + +### Minor Changes + +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + - @objectstack/metadata-fs@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 3f74df5bd4e..464b32e769d 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Metadata loading, saving, and persistence for ObjectStack", "type": "module", diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index d8b1520b892..782ed24091a 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,233 @@ # @objectstack/objectql +## 18.0.0-next.2 + +### Minor Changes + +- 584e737: A hook can now tell a child delete that a parent's cascade issues from a hand delete: `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear` + + Clause-②: yes + + When a record is deleted and a relation whose `deleteBehavior` resolves to `cascade` (a `master_detail` unless it declares `restrict`, or a `lookup` / `tree` that declares `cascade`) removes its children, the engine deletes each child as the calling user. The child's `beforeDelete` and `afterDelete` hooks therefore saw the same `ctx.user`, `ctx.session` and `ctx.input` as a user deleting that one child by hand. A guard that freezes settled records, such as a quote's lines once the quote is accepted, could refuse both deletes or neither, and refusing both made the parent, and every record whose cascade reaches it, undeletable. + + - **What the key means.** `ctx.referentialCascadeDelete === true` exactly on a delete the engine's cascade issued: the child delete, in both phases, at any depth (a grandchild that the child's own cascade removes is marked too). It is absent, never `false`, on every other delete: the delete the caller asked for, a user's hand delete of the same child, and any write a hook issues through `ctx.api` during a cascade, deletes included. The `set_null` cleanup update keeps `referentialFieldClear` and never carries this key, and a cascade child delete never carries `referentialFieldClear`. + - **The guard idiom.** Refuse a hand delete of a frozen line and yield to the cascade: + + ```ts + if (ctx.referentialCascadeDelete !== true && ctx.previous?.locked === true) { + throw new Error('This line belongs to a settled quote and cannot be deleted.'); + } + ``` + + - **Only the declared key is the contract.** There is no operation-private (`__`-prefixed) spelling of this fact on the execution context. The engine recognises the delete its cascade issued through an engine-private record keyed on that child's object and id, so nothing on `ctx.api`, `ctx.transaction` or any `__` key can stand in for it. Read `ctx.referentialCascadeDelete`. + - **In a sandboxed hook body.** The key is carried across the QuickJS boundary by contract, so a lowered `*.hook.ts` body reads `ctx.referentialCascadeDelete` exactly as an in-process handler does. + - **spec (minor):** `HookContextSchema` declares `referentialCascadeDelete` (boolean, optional). One optional engine-produced key on the non-strict runtime hook context; nothing previously valid changes meaning. + - **objectql (minor):** `delete()`'s hook-context assembly sets the key on the delete the cascade walk issues. The walk still hands the child the caller's context unchanged, so no other hook-context member moves. + - **runtime (minor):** `buildSandboxContext` and `installCtx` carry the key into the VM. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- cd86e41: fix(objectql,runtime): a stack's `datasourceMapping` is installed before the engine's first schema sync, and the datasources it routes platform objects to are connected then, so a served boot keeps every object in the store its rule names (#23649) + + Clause-②: yes + + A mapping rule is routing, not a hint (ADR-0062, the `datasourceMapping` half of D2): every object it matches resolves to the datasource it names. `AppPlugin.start()` installed the rules late. By then `ObjectQLPlugin.start()` had schema-synced every registered object onto the host's default store, and the auth plugin had written the Default Organization there. A host config declaring `datasourceMapping: [{ default: true, datasource: 'primary' }]` over a declared `primary` reached `Server is ready` with `OS_DATABASE_URL` set (or under `--dev`), and every platform read then failed with `no such table`, because the platform tables and the organization were in the URL store and `primary` had neither. `os start` with `OS_DATABASE_URL` set to a different database than the rule names behaved the same way. A rule that routes platform objects only (`objectPattern: 'sys_*'`) also left its datasource unconnected, because the connect gate counted the app's own objects only. Every platform read then failed with "mapped for object … is not registered". + + **What changes** + + The public surface widens by one export and one event: `@objectstack/objectql` now exports `OBJECTQL_DRIVERS_READY_HOOK`, and `ObjectQLPlugin.start()` triggers the kernel event it names, `objectql:drivers-ready`, which any plugin can subscribe to. + + - **`@objectstack/objectql`.** `ObjectQLPlugin.start()` triggers the kernel event `objectql:drivers-ready` (exported as `OBJECTQL_DRIVERS_READY_HOOK`), with the engine as its argument. It fires once per boot, after the boot drivers are adopted and connected and before the first schema sync, the `sys_metadata` hydration or any read or write. A handler that throws fails the boot. + - **`@objectstack/runtime`.** `AppPlugin` handles that event. It installs the stack's `datasourceMapping`, then connects every declared datasource the rules route an already-registered object to, platform objects included. The connect is the same `connectDeclared` `start()` uses, with the same fail-fast verdict. The first DDL, the hydration and the Default Organization then land on the mapped datasource. On a composition whose engine never fires the event, `start()` installs the rules as before. + + **Unchanged** + + - A config with no `datasourceMapping`, and a rule naming the host's `default`. + - The engine's default driver. The mapped datasource connects after the engine has adopted its `driver.*` services, so it never takes the default slot from a stack plugin's driver. + - Where an object's rows live after boot. The rules were already in force once `start()` had run. + - `os start` on the config-declared default (`datasourceMapping` `{ default: true }` and no URL). The resolver points the host default at the same database, so everything was already in one file, and still is. + - A rule routing only the app's own objects. Their rows were already written to the mapped datasource. The only difference is that the first sync no longer creates an empty copy of their tables in the default store. + + **Refused at boot, as documented.** The docs already say a rule that routes objects to a datasource that cannot be connected fails the boot. A rule that routed only platform objects to such a datasource used to reach `Server is ready` with every platform read failing. It now fails the boot with the connect error and the objects routed to the datasource. The remedy is to make the datasource reachable, or to remove the rule. + + **Still on the default store:** the package registry's own `sys_packages` table. It is not an object, so no rule routes it, and the package service creates and reads it on the engine's default driver. + +### Patch Changes + +- 086fa46: feat(cli,plugin-dev): an app under `os serve` or `DevPlugin` that declares its own `sys_organization` default record page boots, and its page is the only default + + Clause-②: yes (widening) + + A deployment holds at most one `isDefault: true` record page per object, and a second one is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`. `plugin-auth` registers `sys_organization_detail` as the default record page of `sys_organization` unless it is constructed with `registerOrganizationRecordPage: false`. `os serve` and `DevPlugin` construct `AuthPlugin` themselves and never passed that option, so an app served by either one that shipped its own default page for `sys_organization` was refused at boot, with no switch to set. + + - **What changes.** Both roots now read the app's own pages before they construct `AuthPlugin`. When the app declares an `isDefault: true` record page for `sys_organization`, they pass `registerOrganizationRecordPage: false`, and the app's page is the only default. When it does not, `plugin-auth`'s page registers as before. There is no new configuration key: the page the author already wrote is the switch. Plugin order and precedence rules play no part. + - **One rule for both roots.** The reading is `stackDeclaresOrganizationRecordPage(stack, plugins?)`, a new export of `@objectstack/core`. It reads the stack's `pages` (or each package body's, for a multi-package stack) and the bundle of each app plugin already in `plugins`, and judges each page with `defaultRecordPageObject` from `@objectstack/spec/ui`, the reading the registration refusal uses. + - **The refusal's remedy (`@objectstack/objectql`).** `DEFAULT_RECORD_PAGE_CONFLICT`'s message now says to set `isDefault: false` on one of the two pages, "or compose the deployment without the other package's page". It said to compose the deployment without the other package, which a host cannot do when that package is the platform's auth plugin. The code, the `422` status and the named pages are unchanged. + - **Nothing to adopt.** An app that ships its own `sys_organization` default record page boots under `os serve` and `DevPlugin` with no further change. A deployment that composes its own kernel still sets `registerOrganizationRecordPage: false` on `AuthPlugin` itself. +- 989aa48: fix(objectql): an uninstall refused because another package extends one of its objects answers `409 RESOURCE_CONFLICT` with the way out, instead of `500 INTERNAL_ERROR` (ADR-0029) + + Clause-②: no + + `SchemaRegistry.assertPackageUninstallable` refuses to uninstall a package while another package extends an object it owns (a manifest's `objectExtensions`, ADR-0029). `unregisterObjectsByPackage` and `uninstallPackage` raise the same refusal, because they ask it. When the refusal happens does not change, and a refused uninstall still removes nothing. Only the error it throws changes. + + **FROM.** The error was a bare `Error` with no `code` and no `status`. Its message, `Cannot uninstall package "P": object "O" is extended by E. Uninstall extenders first.`, named only the first extended object. `DELETE /api/v1/packages/P` answered `500 INTERNAL_ERROR`, a server failure, although the caller can change the state that blocks the uninstall. + + **TO.** The error carries `code: 'RESOURCE_CONFLICT'` and `status: 409` (and `httpStatus: 409`), so `DELETE /api/v1/packages/P` answers `409 RESOURCE_CONFLICT`. The message starts with the way out: `Uninstall the package that extends an object of "P" first (E), then uninstall "P" again: …`. It then names every extended object with the packages that extend it: the first five objects, then a count of the rest. The thrown error also carries `packageId`, `extendedObjects` and `extenders`. + + **Unchanged.** A package is refused in the same cases as before. A refused uninstall changes nothing: the `sys_packages` row, the registry entry, the stored metadata rows and the disable record all stay. `unregisterObjectsByPackage(id, true)` still does not ask. + + **If you matched the old text.** Match `error.code === 'RESOURCE_CONFLICT'` (HTTP `409`) instead. The message still contains `Cannot uninstall package "P": object "O" is extended by E`, after the way out. The closing `Uninstall extenders first.` is gone. +- 30c33e3: fix(objectql): the boot hydration ERROR names the storage remedies when no datasource is declared and no driver is registered, instead of sending the operator to a datasource nobody configured + + Clause-②: no + + When `sys_metadata` cannot be read at boot, objectql logs one `error` line that says what was not restored and what to fix. The fix half was always "check the datasource behind sys_metadata — connection, credentials, and that the table exists". On a deployment with no datasource declared and no driver registered (a production `os serve` with no `OS_DATABASE_URL`, for one) there is no datasource to check and no credential to fix. + + - **Before.** `ERROR sys_metadata could NOT be read at boot — persisted metadata was NOT restored, … Fix: check the datasource behind sys_metadata — connection, credentials, and that the table exists — then restart.` + - **After, when nothing serves `sys_metadata`.** `ERROR sys_metadata could NOT be read at boot — no storage is configured: no datasource is declared and no driver is registered, so nothing serves sys_metadata. Persisted metadata was NOT restored, … Fix: set OS_DATABASE_URL (e.g. file:./data/app.db, or a postgres:// or mysql:// URL); mount a storage driver in the stack's plugins (e.g. new DriverPlugin(driver) from @objectstack/runtime); or run with --dev (an in-memory SQLite store, for development) — then restart.` The three fixes are the ones the CLI's `⚠ No storage:` line names. + - **Unchanged.** The level stays `error`, and the consequence sentence is the same word for word. The datasource remedy stays for every case where a datasource exists: a registered driver whose read failed, `sys_metadata` bound to a named datasource with no driver, and a declared datasource the connection layer could not use. Which line prints is read from the engine's routing, not from the text of the error the read raised. +- 3bc407d: fix(objectql): the metadata service's copy of a runtime-authored object follows its saves and deletes after boot, so the MCP stdio door enforces an object's current API exposure and no longer lists a deleted object + + Clause-②: no + + On a kernel whose `ObjectQLPlugin` has no `environmentId` (the CLI host-config boot, the kernel factory, a control plane), the plugin copies every registry object into the `metadata` service once, at boot, and no authoring door wrote to that service afterwards. For a runtime-authored object (one saved through Studio, the REST metadata door or an AI author) the service's answer was therefore the boot body, or no body at all: + + - **An object saved after boot had no copy.** The MCP stdio door gates every data verb on the copy's `enable.apiEnabled` and reads an absent copy as no declaration, so `query_records` was served on an object saved with `apiEnabled: false`. + - **An object re-saved after boot kept its boot copy**, so when its exposure was turned off `query_records` and `create_record` were served on it, and the create wrote a row. + - **A deleted object kept its copy**, so `list_objects` and `describe_object` still named it. Its data verbs were already refused by the engine. + + The copy now follows the registry on the same signal every authoring door already emits (`onMetadataMutation`, which also reaches a cluster peer once its registry has converged): + + - **A save refreshes the copy before the save returns**, through `registerInMemory` (synchronous, no persistence, no announce): the object's current `enable` is what the exposure gate reads. An object created after boot gets a copy. + - **A delete removes the copy**, once the stored row is gone and the registry no longer serves the name. If the object is created again while the removal is still running, the new copy is put back. + - **A code package's copy is never touched.** Only a copy of a runtime-authored object (the registry's owner row for the name is tenant-authored, or the copy already says so) follows the registry; the package copies the protocol restores baselines from stay exactly as the boot bridge made them. + - **A refresh that cannot land is logged at `error`**, naming the object, the consequence (readers of the metadata service keep the previous definition) and the way out. + + Every other reader of `IMetadataService.getObject` and `listObjects` on such a kernel sees the same current definition. The runtime dispatcher's exposure gate (`refuseUnexposedDataAction`, which the HTTP MCP door's data verbs run through) is one: it allowed an object saved with `apiEnabled: false` after boot, and refuses it now. A project-scoped kernel (one built with an `environmentId`) does not run the boot bridge and is unchanged. +- 76560a1: fix(objectql): on a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, the cloud single-environment runtime), a runtime-authored object now has a metadata-service copy, so both MCP doors refuse an object whose API exposure is off + + Clause-②: no + + On a kernel built with an `environmentId`, the `metadata` service is sourced from the artifact and the boot bridge does not run. Until now, the copy follow that keeps a runtime-authored object's copy current also returned early on that shape. So the service held **no** copy of **any** object saved through Studio, the REST metadata door or an AI author, and the MCP exposure gates read that absence as "no declaration". Measured on the real `os dev` (`env_local`), where the REST data door refused the same objects with `OBJECT_API_DISABLED`: + + - **Saved with `apiEnabled: false`, or turned off after a save.** The HTTP MCP door served `query_records` and `create_record`, and the create wrote a row. The MCP stdio door did the same. + - **Saved with `apiEnabled: false`, then a restart.** Still served: the hydrated object had no copy, and no save fired after boot. + - **Exposed objects.** `list_objects` did not name them, and `describe_object` answered "not found". + + The fix is at the copy's producer, and it bounds what is written the same way the no-`environmentId` shape already does: + + - **The copy follow runs on every kernel shape.** A save refreshes the copy before it returns, through `registerInMemory` (in memory, no persistence, no announce). A delete removes it once the stored row is gone and the registry no longer serves the name. + - **A kernel that hydrates its own `sys_metadata` (`hydrateMetadataFromDb`) copies its hydrated runtime-authored objects once at boot.** That option is set only on a kernel whose registry is its own. Only objects whose registry owner row is tenant-authored are copied, so a code package's objects still reach the service from the artifact alone. The full boot bridge stays off on this shape. + - **A boot copy that cannot land is logged at `error`.** The line names the objects, the consequence (the MCP exposure gates serve them even when their exposure is off) and the way out. + + Both exposure gates read this service: the MCP stdio door's `enforceApiExposure`, and `refuseUnexposedDataAction`, which the HTTP MCP data verbs run through. Both now answer `OBJECT_API_DISABLED` / 404 for such an object, which is what `apiEnabled: false` declares. Neither gate's fall-open rule for an absent definition changes. The kernel shape without an `environmentId` is unchanged. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [3b28905] +- Updated dependencies [1baf976] +- Updated dependencies [222293f] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [7cb33cf] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [56147a8] +- Updated dependencies [23e689d] +- Updated dependencies [a869ea6] +- Updated dependencies [7b64a3d] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [c03077e] +- Updated dependencies [9bb9370] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-protocol@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index a2bf8d4a09a..a4a6d2ead5f 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Isomorphic ObjectQL Engine for ObjectStack", "main": "dist/index.js", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index 59bfc42296d..66538af5e19 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/observability +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 49c4335ad08..0fc5967ccc7 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.", "type": "module", diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index 162fbd13bd4..1b1529509cb 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,237 @@ # @objectstack/platform-objects +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. + +### Patch Changes + +- 0d3ea29: fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + + Clause-②: no + + Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + + - **`@objectstack/plugin-security`.** + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. + - **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. + - **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. + - **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. + - **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. +- 1692cd1: feat(spec): the page form offers the list page's own empty state, `interfaceConfig.emptyState` + + Clause-②: no + + The console's list page draws the page's own `interfaceConfig.emptyState` in place of the source view's, so the page metadata form now offers it. + + - **`@objectstack/spec`**: `pageForm`'s Interface section gains an `emptyState` control under `interfaceConfig`, a popover of the list view's `EmptyStateSchema` keys (`title`, `message`, `icon`, `link`), placed after the add-record entry point. No schema changes: `InterfacePageConfigSchema.emptyState` already parsed, so every page that was valid before is valid now, and nothing new is accepted. + - **`@objectstack/platform-objects`**: the metadata-forms translation bundles carry the new control's label and help text, translated in `zh-CN`, `ja-JP` and `es-ES`. +- 3f0a307: fix(platform-objects): the `sys_session.revoke_reason` description names `admin_password_reset`, the value an administrator's forced password reset writes on the sessions it revokes. Description text only; nothing is accepted or refused differently. +- be48c5e: feat(spec)!: retire the delegated-admin scope's `manageBindings` and `authorEnvironmentSets` — rights no door honoured (#23488) + + **BREAKING** — `manageBindings` and `authorEnvironmentSets` on a permission set's `adminScope` (`AdminScopeSchema`, ADR-0090 D12) are now refused at parse. They named the right to bind sets to positions and the right to author environment permission sets, and they gated the `sys_position_permission_set` and `sys_permission_set` rows, which ADR-0131 D3 retired. A position's sets are part of its definition and a permission set is a definition; both are written through the metadata door, which admits a `manage_metadata` holder and reads no `adminScope`. So a delegate who "may bind" or "may author" was granted nothing. Delete both keys: a delegate's scope is placement, assignment and the allowlist. + + Clause-②: yes (narrowing) + + Measured before removal, at `cdc42eb49`: no production author set either key to `true` (the showcase wrote the default `false`; the control key `manageAssignments: true` has four production authors), and no door that writes a position or a permission set consulted either key. Their one reader was the delegated-admin gate's strict-containment comparison, which changes below. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `adminScope: { …, manageBindings: true \| false }` | nothing — delete the key | + | `adminScope: { …, authorEnvironmentSets: true \| false }` | nothing — delete the key | + | a delegate who re-composes what a position distributes, or authors a set's body | a `manage_metadata` holder writes the position or set definition through the metadata door (ADR-0131 D3); the delegate assigns people to positions and grants allowlisted sets inside its subtree | + | reading `scopes[].manageBindings` / `scopes[].authorEnvironmentSets` from `GET /api/v1/security/my-delegable-scope` (`DelegableAdminScope`) | nothing — the report carries `manageAssignments` as the scope's one action right | + + **The one-line fix:** delete `manageBindings` and `authorEnvironmentSets` from every `adminScope`. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`AdminScope` types both keys as retired), and the parse — `definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name` — refuses it at its own path with the prescription: + + > `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The containment change (`@objectstack/plugin-security`) + + The delegated-admin gate judges a delegate who hands out a set that itself carries an `adminScope` — a `sys_user_position` assignment of a position that distributes it, or a direct `sys_user_permission_set` grant — by **strict containment**. That comparison read both retired flags. It now reads `includeSubtree`, `manageAssignments` and the allowlist only: + + - a delegate whose own scope set either flag `true` can **no longer** hand out a scope otherwise EQUAL to its own — the inert flag used to count as the strictly-larger axis and let that lateral copy through the equal-scope refusal. It is refused `403 PERMISSION_DENIED` (`… would hand out an adminScope not strictly contained by yours`); + - a granted scope that set either flag `true` no longer counts as out-reaching a delegate that did not. + + `DelegatedAdminGate.describeDelegableScope()` (and so `GET /api/v1/security/my-delegable-scope`) no longer reports either flag. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `AdminScopeSchema`, a `strictObject` — so each refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/AdminScope:manageBindings`, `security/AdminScope:authorEnvironmentSets`. `DelegableAdminScope` (`@objectstack/spec/contracts`) drops both fields. + - **The D2 conversion `permission-admin-scope-binding-authoring-removed`** (protocol 18, retired from the load path) deletes both keys from every `permissions[].adminScope`, one notice per key, whatever they hold. Both keys carried the default `false`, so a 17.x parse materialized them in every declared scope; the rehydration seams replay this conversion over stored `permission` rows and built artifacts, and `os migrate meta --stored` lists the rows that carry them before the upgrade. + - **The D3 entry `permission-admin-scope-binding-authoring-retired`** records the containment change above: the judgement the strip cannot make for the upgrader. + - **`@objectstack/core`:** `permissionSetDefinitionFromRow` — the mapping `os migrate`'s legacy-row ceremony saves a `sys_permission_set` row through — drops both keys from the row's `admin_scope`. That save goes through `saveMetaItem`, which replays no conversion: measured, a 17.x row's body answered `422 INVALID_METADATA` at `adminScope.manageBindings`, and the same body without the keys saved. + - **`@objectstack/platform-objects`:** the permission-set form's `adminScope` help text (all four locales) names the scope's live keys only. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's two switches for these keys retire in an objectui follow-up. + + +- c5dd7fc: feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. + + Migration: + + - `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it. + - A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`. + + `os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door. + + The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. + + `@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`. + + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 3527e106dcf..47e22daaca2 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects", "main": "dist/index.js", diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index 4583a2b0d04..a83db559430 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/embedder-openai +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 9f592bf92f9..8745e178de1 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md index 6e11665abe6..794cc3af53d 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,54 @@ # @objectstack/knowledge-memory +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/service-knowledge@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 647d67da550..68d0bf15926 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md index 12c64beb231..a750b3ca0cf 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,54 @@ # @objectstack/knowledge-ragflow +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/service-knowledge@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 3480d223dbf..00d70f3505a 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/organizations/CHANGELOG.md b/packages/plugins/organizations/CHANGELOG.md index 05de441efde..5ef6c7ac077 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,69 @@ # @objectstack/organizations +## 18.0.0-next.2 + +### Patch Changes + +- b1e9280: fix(plugin-security, organizations): the seed-ownership claims hand over only the rows the seed wrote, and a guest-written or public-form row keeps no owner (ADR-0138 D1) + + The claim that hands seeded records to the platform admin runs on every `app:seeded`, so on every boot of an app with seed data. Its NULL-owner arm re-owned every unowned row of every eligible object, and no stored column tells a seed row from a guest-written row or a public form's intake. Those rows keep no owner on purpose (ADR-0138 D1: "the owner stays unset"), and the claim handed them to the platform admin at the next boot. + + - **`claimSeedOwnership`** (`@objectstack/plugin-security`) re-owns a NULL-owner row only when the seed reported landing on it: `owner_id IS NULL AND id IN (seeded ids)`, in chunks of half the per-row hook ceiling, read through the `seed-settlement` contract's `seededRecordIds`. The settle pass (`app:seeded`) and the first-admin promotion (`bootstrapPlatformAdmin`, new option `seededRecordIds`) both pass the reading. A replay reports the rows an earlier boot wrote, so a seed row is still claimed on a later boot. The `usr_system` arm is unchanged: only a seed or a legacy install writes that owner. + - **`claimOrgSeedOwnership`** (`@objectstack/organizations`) takes the same selector, read for the organization the owner bind claims for: the rows that organization's seed replay landed on, which ran in the same call that created it. Each write carries at most half the per-row hook ceiling's worth of ids, so none is refused for its size and the claim no longer reads pages. + - A row a hook, a flow or an assignment rule gave an owner keeps it, as before. + + **Direct callers.** Called without `seededRecordIds`, `claimSeedOwnership` re-owns only `usr_system`-owned rows and its pass report says no seed reading was published; `claimOrgSeedOwnership` re-owns nothing and says so. The deprecated `ensureDefaultOrganization` wrapper in `@objectstack/organizations` holds no kernel, so it hands over no row; use `OrganizationsPlugin`, which binds the reading. + + **Outside the claim's purpose** (handing the boot seed to the first admin), and so no longer re-owned by a later boot's pass: rows written by seed writers that emit no `app:seeded` — a package install's or a publish's seed apply, the dev hot-reload seeder, and the marketplace install's seed. A dataset they share with the boot bundle is re-reported by the next boot's replay and claimed then. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [e611e2b] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index 338ad489da5..a5a4e3dc257 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/organizations", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Multi-organization runtime for ObjectStack — registers the `org-scoping` service that turns single-database row-level Organization isolation on: `organization_id` auto-stamp on insert, per-org seed replay, default-organization bootstrap, and the walled-posture membership-policy gate.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index 43e8629912d..ddd9ff6f215 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,91 @@ # @objectstack/plugin-approvals +## 18.0.0-next.2 + +### Patch Changes + +- a6d0715: fix(plugin-approvals): the `approval.requested` notification names the record by its object label and id, and the empty-slot warning no longer says the request "cannot advance" + + Clause-②: no + + - **The notification names the record's object by its label.** The body of `approval.requested` used to read `A decision on crm_opportunity/JCqLLy5dSug1MldB is waiting on you.`, with the object's API name. It now reads `A decision on Opportunity JCqLLy5dSug1MldB is waiting on you.`, using the label the Approvals Inbox row shows for that object. The API name is used only when the object's schema declares no label, as in the inbox. The body carries the record's id, as before, and not its title, from any source. Title, topic, action link and recipients are unchanged. + - **The empty-slot warning states the slot, not the request's fate.** An approver that expands to nobody used to log `the slot routes to no one and the request cannot advance until someone is added or the approver is re-pointed`. On the default `onEmptyApprovers: 'admin_rescue'` path the next line opened the request on the organization's administrators, who then decided it. The line now reads `the slot names no one, so no one is asked through it. Staff it or re-point the approver; a slate left naming no one is decided by the node's onEmptyApprovers.` What the node does with an empty slate is stated once, by the node-level line that already follows it. + + A patch: both are server-authored texts. No schema, translation key, export or notification field moves. +- 9f81260: fix(plugin-approvals): a request's `record_title` is served only from fields its reader may read, and the one-tap action pages name the record by its id + + Clause-②: no + + - **The inbox title follows the reader's field-level security.** `listRequests`, `getRequest` and every action's read-back row (which the request actions' success messages read) now pick `record_title` from the subject record as served to the reading caller: the same answer the payload snapshot is redacted by (readable, and not masked for that caller). A display field the reader may not read, or is served masked, gives no title, and the key is absent, the shape the redaction gives that field in the payload. A title-eligible field the reader may read still names the record, and a reader who may read the display field gets the same title as before. Deleted records fall back to the reader's redacted snapshot, as before. Nothing is granted: record access stays with object permissions, sharing and row-level rules. + - **The action-link pages name the record by its id.** The session-less confirm and result pages (ADR-0043) have no reader to narrow for, so their "Record" row shows the record id beside the object's label, as the approval notification names it, and never the title. + + A patch: a narrowing of served values only. No schema, route, translation key, export or notification field moves. With no field-visibility source wired, every title is served as before. +- b513343: fix(plugin-approvals): the inbox's referenced-record titles (`payload_display`) and the record-lock refusal's title are served only from fields the caller may read + + Clause-②: no + + - **Referenced-record titles follow the reader's field-level security on the referenced object.** `listRequests` and `getRequest` now pick each `payload_display` title from the referenced record as served to the reading caller: the same answer the payload snapshot and `record_title` are narrowed by (readable, and not masked for that caller), asked for the referenced object, once per object per read. A display field the reader may not read on the referenced object, or is served masked, gives that key no display value, so the snapshot's stored id stands, as it already does for a deleted referenced record. A title-eligible field the reader may read still names the record, and a reader who may read the display field gets the same title as before. + - **The `RECORD_LOCKED` refusal names the record only from what the writer may read.** The per-record refusal sentence takes its title from the record as served to the refused writer, asked with the writer's own execution context. When the writer may not read the display field, or is served it masked, the sentence names the object's label alone (`This Opportunity is locked …`), as it already does for a record with no title; it never names the id. A writer who may read the field is told the title as before. The code (`RECORD_LOCKED`) and status (409) are unchanged. + + A patch: a narrowing of served values only. Nothing is granted, and no schema, route, error code, translation key or export moves. With no field-visibility source wired, every title is served as before. +- 7e6e4e1: fix(plugin-approvals): the inbox's referenced-record titles (`payload_display`) are served only for a referenced record the caller may read + + Clause-②: no + + - **Referenced-record titles follow the reader's access to the referenced RECORD.** `listRequests` and `getRequest` now title a lookup in the payload snapshot only when the reading caller may read the record it points at, asked through the platform's one caller-scoped row-access answer (`resolveReadableParentIds`, the same one the comment, activity, attachment and mail gates ask), once per referenced object per read. So the referenced object's object-level read, its sharing and its row-level rules decide, as they already do for the data API's `$expand`. A referenced record the reader may not read gets no display value and the snapshot's stored id stands, as it already does for a deleted referenced record. A reader who may read the record gets the same title as before, still picked from the fields they may be served. A referenced object the reader may not read at all is first skipped by the security service's object-level read admission (`canReadObject`), so an inbox read does not add an access-denied warning to the server log for it. + - **Nothing about the record under approval changes.** Its title (`record_title`), its label and its field-level-security-redacted snapshot are served to a routed approver exactly as before, whatever their access to that record. + + A patch: a narrowing of served values only. Nothing is granted, and no schema, route, error code, translation key or export moves. With no security plugin composed, every caller-scoped read answers every record, so every title is served as before. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index ed352526252..d345bcdd185 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md index 29cc1b00a4a..9189b032982 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,90 @@ # @objectstack/plugin-audit +## 18.0.0-next.2 + +### Minor Changes + +- 69351e9: feat(plugin-audit): the `sys_activity` and `sys_audit_log` read gates write one `ReadableIds` leaf per parent object and do no pre-scan, so a broad read is judged over every row, not a 2,000-row window (ADR-0139 D4, D5) + + Clause-②: no + + Both gates serve a row only when the caller may read the record it is about. Until now each read first scanned at most 2,000 candidate rows as the system, then asked which of those rows' parent records the caller could read. Past that window the gate failed closed: the unscanned rows were left out, the list's `total` agreed with the short answer, and a warning said so. + + Each gate now writes one branch per object the metadata registry holds (outside the audit writer's excluded objects and the gated object itself): `{ object_name: P, record_id: { $in: ReadableIds.of(P) } }`. The engine composes the leaf from the security service's `canReadObject` and `getReadFilter` for `P`, and the SQL drivers evaluate it as one subquery against `P`'s table, inside their own tenant wall. A list, its `total` and a grouped count are each one statement over every row the caller may read. A parent object the caller may not read is answered with no rows, without reading it as the caller, so no denial is logged for it. + + What a reader sees: + + - `sys_audit_log`: a broad read is served every row about a record the caller may read, and every row about no record, with a true `total`, whatever the table size. The rows about no record (`config_change`, `import`, `platform_admin_standing_change`, an auth event with no session) are now a filter on the ledger's own columns rather than a list of ids the scan saw. A holder of `view_all_audit_log` is still not narrowed. A row about a session is still served when that session is readable. + - `sys_activity`: the gate itself has no window any more, but the stream's withheld-update rule still pre-scans 2,000 rows as the system, so a broad read stays bounded by that rule until it becomes a predicate (ADR-0139 E6). A read about one record is unchanged. + - Three classes still read through the bounded probe, chosen before any leaf is written: a delegated (on-behalf-of) caller; a parent an object-scoped read middleware narrows (for example `sys_attachment`, `sys_comment_reaction`, `sys_approval_request` and the settings objects); and a parent bound to another datasource than the gated object, which covers every parent on a deployment that registers the `telemetry` datasource. The whole read also stays on the probe when the security service offers no `canReadObject`, or when none is registered, as before. + - On a driver that does not compile the leaf (`driver-mongodb`), a read of either object now fails with `INTERNAL_ERROR` / 500 naming the driver and the leaf, instead of serving a capped list. The gates do not turn that refusal into an empty answer. This is ADR-0139's ruled behaviour until a pipeline form is measured for that driver. + +### Patch Changes + +- 94924e0: fix(plugin-audit): the `sys_activity` and `sys_audit_log` read gates skip a parent object the caller cannot read without reading it, so the security plugin no longer logs a denial for each skipped object + + Clause-②: no + + Both gates serve a row only when the caller can read the record it is about. They decide that once per parent object the query's rows name, by reading that object as the caller. For every object the caller holds no object-level read on, plugin-security refused that read and logged `[Security] Access denied` at WARN: one line per object per read, and again for the list's `total`. The console header's activity feed reads `sys_activity` on every page view, so every non-admin page view logged a line for each platform object the stream names (`sys_migration`, `sys_platform_setting`, `sys_user_permission_set`, and others), and real denials were buried among them. + + Each gate now asks the security service's `canReadObject` first, with the caller's own context. That is the security middleware's own object-level read check, and it answers without logging. The gate then reads only the objects it admits. An object it refuses contributes no rows, exactly as its refused read did before, so every caller is served the same rows, with the same `total`, as before. A caller's own read of such an object is still refused and still logged, once. + + The check can only exclude objects. An admitted object is still read as the caller, and that read still decides row scope, sharing and RLS. If `canReadObject` throws, or answers anything other than `true`, that object's rows are excluded and the gate logs a warning naming the object. On a deployment without the security plugin, or with a security service that does not offer `canReadObject`, every parent object is read as the caller, as before. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [989aa48] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [76560a1] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 24d58ea82a9..538b75eac55 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", "main": "dist/index.js", diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md index e864c45c6ab..cf6909a1746 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,157 @@ # Changelog +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 0be162c: fix(plugin-auth)!: impersonation, SSO-provider administration and the admin plugin's role-scalar actions refuse a caller the ADR-0069 authentication policy holds + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 D3 holds a signed-in user out of every protected resource while their password has expired (or an administrator has forced a change) or while enforced MFA is not yet enrolled, until they remediate. REST, the runtime dispatcher and the platform-admin actions plugin-auth mounts on the host server already refuse such a user. Three doors better-auth serves itself did not, because their session is a raw read that never carries the posture: + + - **Impersonation** (`POST /admin/impersonate-user`): a held platform administrator could mint a session as another user, and that session is not held, so the hold was bypassed outright. + - **SSO-provider administration** (`POST /sso/register`, `/sso/update-provider`, `/sso/delete-provider`): a held platform administrator could register an identity provider, and rewrite the issuer of, or delete, one they own. + - **The admin plugin's own actions, judged on the legacy `role` scalar** (set-role, get-user, update-user, list-users, list-user-sessions, revoke-user-session, revoke-user-sessions): a held caller carrying `role = 'admin'` was admitted, writes included. + + From → to. Before: a held caller passed these doors as if unheld. Now: each door asks the auth service for the caller's posture in the organization their session operates in (the same computation, and the same first entry, the session carries) and refuses a held caller with `403`, `{ success: false, error: { code, message } }`, where `code` is the posture's own (`PASSWORD_EXPIRED`, `MFA_REQUIRED` or `PASSWORD_RESET_REQUIRED`) and `message` names the remedy: the refusal the session gate and `judgePlatformAdmin` already give. No new error code. + + - **Order:** an anonymous caller still meets the vendor's `401` first. A held signed-in caller who is not an administrator now meets the posture's `403` code instead of the vendor's `403 YOU_ARE_NOT_ALLOWED_*`: same status, the remedy named. + - **Unchanged:** a caller the policy does not hold is served exactly as before. Stopping an impersonation and the admin plugin's permission question are not held, and sign-in, change-password, two-factor enrollment and sign-out still answer a held session, so remediation works as before. A posture lookup that fails holds no one, as it does for the session. + - **New export:** `judgeHeldSession` (`@objectstack/plugin-auth`), the held refusal `judgePlatformAdmin` gives, now shared with these doors. + + What changes for you. An administrator the policy holds must remediate (change the password, or enroll an authenticator) before impersonating a user, registering or changing an SSO provider or using the admin plugin's user actions, as on every other protected route. +- fc1a55a: fix(plugin-auth): the identity import invites with `auth.account_invitation`, not the password-reset email + + Clause-②: yes (widening) + + A user created through Setup › Users › Import (`POST /api/v1/auth/admin/import-users`) and invited by email now receives the new `auth.account_invitation` template from `@objectstack/plugin-email`. + + - **FROM → TO for an imported user's email.** FROM `auth.password_reset` ("We received a request to reset the password … If you didn't request this, ignore this email"), although the user requested nothing. TO `auth.account_invitation`: the administrator who ran the import (name and email, as their user record holds them), the new account's address, a "Set your password" link, and the link's real expiry. The link is the same one as before: it lands on the Console's set-password page, and its token is the same better-auth reset token. + - **Every other password reset is unchanged.** A user who asks for a reset (`POST /api/v1/auth/request-password-reset`) still receives `auth.password_reset`, whether or not they were imported. Only the import's own request sends the invitation: the import marks that one request on the server, and nothing a client sends can set the mark. `auth.invitation` (an invitation into an organization) is unchanged. + - **If you customized `auth.password_reset` for imported users,** move that wording to `auth.account_invitation`: imported users no longer receive `auth.password_reset`. + - **The stated expiry is the token's real lifetime.** better-auth gives the token `emailAndPassword.resetPasswordTokenExpiresIn` seconds, or 3600 when that is unset or `0`. A configured `0` used to make the reset email say "expires in 0 minutes"; it now says 60, as the token does, and so does the invitation. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. + +### Patch Changes + +- e611e2b: fix(plugin-auth): a user import reports an invitation that the email transport failed to deliver + + Clause-②: no + + Setup › Users › Import (`POST /api/v1/auth/admin/import-users`) used to report an email invitation as delivered when the email transport failed. The import invites by asking better-auth for a password reset, and better-auth catches whatever the send throws and still answers `{ status: true }`. So the import's `INVITE_EMAIL_FAILED` path was never reached by a failed delivery: the row read `delivery: 'email'`, and `summary.errors` stayed `0`. + + - **A failed invitation is now reported on its row.** This covers a transport that answers `failed` and a transport that throws. The row is `ok: false`, has no `delivery`, carries `code: 'INVITE_EMAIL_FAILED'` and an `error` naming the transport's reason, and is counted in `summary.errors`. Its `action` stays `'created'` and its `id` stays, because the account exists. The fix is to send the invitation again or to set the user's password as an admin. The import does not roll the row back and does not fall back to a temporary password. + - **The SMS invitation reports a failure the same way.** A phone-only row whose invitation SMS fails is `ok: false` with no `delivery`, carries `code: 'INVITE_SMS_FAILED'`, and is counted in `summary.errors`. Before, it read `delivery: 'sms'` and was not counted. + - **A delivery is reported only when the email service accepts it.** A row is reported delivered when the service answers `sent` or `queued`. If the reset request answers without the service accepting the message, the row is reported failed, never delivered. + - **`summary.delivery` counts only deliveries that succeeded.** A row whose invitation failed is counted in `summary.errors` and in `summary.created`, so `created + updated + skipped + errors` exceeds `total` by exactly those rows. + - **A successful invitation is reported as before.** A public `POST /api/v1/auth/request-password-reset` is unchanged: it answers the same thing whether the address's mail went out, failed, or has no account behind it. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/rest@18.0.0-next.2 + - @objectstack/service-messaging@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 1b1fa063ce5..6a093a2b797 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Authentication & Identity Plugin for ObjectStack", "main": "dist/index.js", diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md index d257d56b37f..f6ea6e5c269 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,109 @@ # @objectstack/plugin-dev +## 18.0.0-next.2 + +### Minor Changes + +- 086fa46: feat(cli,plugin-dev): an app under `os serve` or `DevPlugin` that declares its own `sys_organization` default record page boots, and its page is the only default + + Clause-②: yes (widening) + + A deployment holds at most one `isDefault: true` record page per object, and a second one is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`. `plugin-auth` registers `sys_organization_detail` as the default record page of `sys_organization` unless it is constructed with `registerOrganizationRecordPage: false`. `os serve` and `DevPlugin` construct `AuthPlugin` themselves and never passed that option, so an app served by either one that shipped its own default page for `sys_organization` was refused at boot, with no switch to set. + + - **What changes.** Both roots now read the app's own pages before they construct `AuthPlugin`. When the app declares an `isDefault: true` record page for `sys_organization`, they pass `registerOrganizationRecordPage: false`, and the app's page is the only default. When it does not, `plugin-auth`'s page registers as before. There is no new configuration key: the page the author already wrote is the switch. Plugin order and precedence rules play no part. + - **One rule for both roots.** The reading is `stackDeclaresOrganizationRecordPage(stack, plugins?)`, a new export of `@objectstack/core`. It reads the stack's `pages` (or each package body's, for a multi-package stack) and the bundle of each app plugin already in `plugins`, and judges each page with `defaultRecordPageObject` from `@objectstack/spec/ui`, the reading the registration refusal uses. + - **The refusal's remedy (`@objectstack/objectql`).** `DEFAULT_RECORD_PAGE_CONFLICT`'s message now says to set `isDefault: false` on one of the two pages, "or compose the deployment without the other package's page". It said to compose the deployment without the other package, which a host cannot do when that package is the platform's auth plugin. The code, the `422` status and the named pages are unchanged. + - **Nothing to adopt.** An app that ships its own `sys_organization` default record page boots under `os serve` and `DevPlugin` with no further change. A deployment that composes its own kernel still sets `registerOrganizationRecordPage: false` on `AuthPlugin` itself. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [249130f] +- Updated dependencies [b1e9280] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [e1cefa5] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [23e689d] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [e611e2b] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [f77ba41] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/plugin-security@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/rest@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + - @objectstack/service-storage@18.0.0-next.2 + - @objectstack/service-i18n@18.0.0-next.2 + - @objectstack/plugin-hono-server@18.0.0-next.2 + - @objectstack/driver-memory@18.0.0-next.2 + - @objectstack/account@18.0.0-next.2 + - @objectstack/setup@18.0.0-next.2 + - @objectstack/service-realtime@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 91536ff779b..baec5225471 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index e6083c642ab..e1b54b2582b 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,70 @@ # @objectstack/plugin-email +## 18.0.0-next.2 + +### Minor Changes + +- fc1a55a: feat(plugin-email): a built-in `auth.account_invitation` email template, for an account an administrator created, in four locales + + Clause-②: yes (widening) + + A new built-in template, `auth.account_invitation`, ships in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so it is listed and resolved the same way, and a Studio save overrides it for an environment. + + - **When it is sent.** `@objectstack/plugin-auth` sends it when an administrator creates an account for someone and emails them a link to set their first password. Today that is the identity import (Setup › Users › Import, `POST /api/v1/auth/admin/import-users`). + - **What an imported user receives, FROM → TO.** FROM the password-reset email ("We received a request to reset the password … If you didn't request this, ignore this email"). TO an invitation that names who created the account, the account's address, a "Set your password" link and when that link expires. + - **Its variables.** `user.name` (optional), `user.email`, `inviter.name`, `inviter.email`, `setPasswordUrl`, `expiresInMinutes`, and `appName` (optional). Every one except the two optional ones is required, so a send without one is refused with `MISSING_VARIABLES` rather than half-rendered. It names no organization and no role: an imported user may have neither. + - **Exports.** `AUTH_ACCOUNT_INVITATION_TEMPLATE`, its three locale rows (`_ZH_CN`, `_JA_JP`, `_ES_ES`) and the list `AUTH_ACCOUNT_INVITATION_TEMPLATES`. `BUILTIN_AUTH_TEMPLATES` grows from 24 rows to 28. + - **Unchanged.** `auth.invitation` (an invitation into an organization) and `auth.password_reset` render byte for byte as before, in every locale. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index bd9cc440785..021c0d3c967 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md index 1fd43e34e06..46d314104bf 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/plugin-hono-server +## 18.0.0-next.2 + +### Minor Changes + +- e1cefa5: fix(hono): `createHonoApp` answers an escaped throw that declares an ADR-0112 envelope as that envelope, not as `500 INTERNAL_ERROR` (#23365) + + `@objectstack/hono`'s `createHonoApp` has two catch arms: the auth mount's, and + the `${prefix}/*` catch-all's around `HttpDispatcher.dispatch()`. Both answered + an escaped throw from its `statusCode` alone, so a producer that declared + `status` and a registered `code` lost both on this host. A `GET`, `PATCH` or + `DELETE` of `/data/:object/:id` for an id that names no row answered + `500 INTERNAL_ERROR` where the standalone REST server answers + `404 RECORD_NOT_FOUND`. The `/data` version refusals answered `500` too (a stale + `If-Match`'s `409 CONCURRENT_UPDATE`, and `If-Match: ""`'s + `400 VALIDATION_FAILED`), and so did an authorization-store outage + (`AuthzStoreUnavailableError`, `503 SERVICE_UNAVAILABLE`). + + **What changed.** Both arms now answer a throw that carries **both** a declared + ADR-0112 status (a key of `HttpStatusErrorCodeMap`) **and** a code registered in + `ErrorCode` as that envelope. The recognition is `declaredEnvelopeForThrow`, the + rule `@objectstack/plugin-hono-server`'s direct-mount routes have used since + #16545, so the two Hono doors answer one throw alike by construction: the same + two conditions, the same 5xx disclosure filter (`looksLikeInternalErrorLeak`), + and `details` and `userMessage` forwarded only when the producer declared them. + + The rule's other limbs come with it, as they did on the direct-mount door. A + validation-shaped throw answers `400 VALIDATION_FAILED` with its `fields[]`. A + driver fault the engine has already classified keeps its `500` and now carries + the code it declared instead of `INTERNAL_ERROR`. Measured: `GET /data/:object` + over an object whose table is missing answers `500 DATABASE_ERROR`, with the + same message as before. + + **What did NOT change**, pinned in the same PR through a real `createHonoApp` + over the real dispatcher and protocol (`packages/qa/http-conformance`): + + - a throw that is **not** such an envelope answers what it answered before. A + bare `Error` is still `500 INTERNAL_ERROR` carrying `code` and `message`, with + no `cause` in the body, and a throw that declares `statusCode` with no + registered code keeps that status; + - ⛔ no error code is minted and no ledger row is added. + + `@objectstack/plugin-hono-server` gains one export, `declaredEnvelopeForThrow`, + which was module-private. `createHonoApp` imports it rather than restating the + rule. Its behaviour is unchanged. + + Earlier entries say a `createHonoApp` host still renders the `/data` version + refusals as `500`. This change ends that: they are answered `409` and `400` as + declared. + + The `409 CONCURRENT_UPDATE` this host now answers carries `currentVersion` and + `currentRecord` in `error.details`, the two fields the standalone REST server's + `409` carries; `currentRecord` is the row as the caller reads it. + + Clause-②: yes (widening) + +### Patch Changes + +- 19c554d: fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentVersion`, as the standalone REST server's does (#23531) + + A stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `409 CONCURRENT_UPDATE`. The standalone REST server's answer carries the server's `currentVersion`, which `wire-format.mdx` documents and a conflict dialog needs: it is the token "Overwrite anyway" sends back as the next `If-Match`. The two Hono doors answered the same refusal without it. They are `@objectstack/hono`'s `createHonoApp`, which answered it `500 INTERNAL_ERROR` (the `@objectstack/hono` entry for #23365 makes it a `409`), and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, which answered it with `code` and `message` only. + + - FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` + - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` + + The field rides `error.details`, the wrapped envelope's declared context slot. `@objectstack/client` reads a wrapped body's context from there, so `err.details.currentVersion` now carries it on these hosts too. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the `409` the `@objectstack/hono` entry for #23365 states. No other answer changes. + + `currentRecord` rides beside it, as on the REST server: the row as the caller reads it, which the protocol re-reads under the caller's own context on a version mismatch (#23586). + + Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. + + Clause-②: no +- 5bb9a83: fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531) + + The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried neither field: the route answered it with `code` and `message` only, and `createHonoApp` answered it `500 INTERNAL_ERROR`, which the `@objectstack/hono` entry for #23365 makes a `409`. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it beside the server's `currentVersion`. + + - FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` + - TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` + + `currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the `409` the `@objectstack/hono` entry for #23365 states. No other answer changes. + + Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. + + Clause-②: no +- f7cd580: fix(plugin-hono-server): a structured refusal answered by either Hono door carries the fields the standalone REST server's answer carries, such as `409 DELETE_RESTRICTED`'s `developerMessage`, `dependentObject` and `dependentCount` (#23587) + + Deleting a record that a `deleteBehavior: 'restrict'` lookup still references is refused by the engine with `409 DELETE_RESTRICTED`. The standalone REST server's answer carries the engine's `developerMessage` (the API names and the `deleteBehavior: 'cascade'` remedy), `dependentObject` and, when the caller may see it, `dependentCount`. The two Hono doors answered the same refusal without them. They are `@objectstack/hono`'s `createHonoApp`, which answered it `500 INTERNAL_ERROR` (the `@objectstack/hono` entry for #23365 makes it a `409`), and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, which answered it with `code` and `message` only. + + - FROM `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…"}}` on a `HonoHttpServer` route, and `500` `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"…"}}` on `createHonoApp` + - TO `{"success":false,"error":{"code":"DELETE_RESTRICTED","message":"…","details":{"developerMessage":"…","dependentObject":"…","dependentCount":1}}}` + + The same rule now covers every refusal the REST server answers with structured fields, not only `409 CONCURRENT_UPDATE`'s `currentVersion`: `400 INVALID_FIELD` carries `field` and `object`, `404 OBJECT_NOT_FOUND` carries `object`, the `403` capability and access refusals (`FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`) carry `object`, and a `409 METADATA_CONFLICT` thrown out of a direct-mount route carries `currentVersion` (on `createHonoApp` the `/meta` save is answered by the dispatcher itself, which this does not change). The fields ride `error.details`, where `@objectstack/client` reads a wrapped body's context, so `err.details.developerMessage` and its siblings carry them on these hosts too. They are read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so each appears only when the producer stated it. On a `HonoHttpServer` route, status, `code` and `message` are unchanged; on `createHonoApp` they are the declared envelope the `@objectstack/hono` entry for #23365 states. A refusal whose classification answers a different status or code gains nothing. The engine's duplicate insert is the one envelope these doors translate first: its `DUPLICATE_RECORD` is answered `409 UNIQUE_VIOLATION`, the REST server's code, so the classification agrees and its fields ride `details`, as this package's `UNIQUE_VIOLATION` entry states. + + One field stays off these doors: `object` on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `METADATA_CONFLICT` is the object the request's route named, and these doors answer without a route. The REST server's answer is unchanged. + + Clause-②: no +- c9810dd: fix(plugin-hono-server): a repeated unique value answered by either Hono door reports the wire code `UNIQUE_VIOLATION`, not the engine's in-process `DUPLICATE_RECORD`, with `developerMessage`, `object` and `field` (#23587) + + **Wire change at these doors: `409 DUPLICATE_RECORD` becomes `409 UNIQUE_VIOLATION`.** A client of `@objectstack/hono`'s `createHonoApp` or of a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer` that matched `code === 'DUPLICATE_RECORD'` on a duplicate insert or update must match `code === 'UNIQUE_VIOLATION'` instead, which is what the standalone REST server already answers for the same refusal. + + Why: the maintainer ruled one wire spelling for a unique-constraint refusal on every route, `UNIQUE_VIOLATION` (recorded in commit 65846bc46). The REST server, the batch rows of `POST /data/:object/batch` and the import runner's rows already report it; the two Hono doors reported the engine's own spelling. + + - FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …"}}` + - TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}` + + `details` carries what the REST server's answer carries beside its code, each only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The `message` is unchanged: it stays the engine's sentence, as on every other code at these doors. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A hook that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. The REST server's answer is unchanged. + + Clause-②: no +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index e51237ecbe0..ea0f1907947 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": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Standard Hono Server Adapter for ObjectStack Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md index 90e5846cfd6..7d9c77035d6 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,28 @@ # @objectstack/plugin-pinyin-search +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [584e737] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [1d79556] +- Updated dependencies [dd29bbe] +- Updated dependencies [daafae6] +- Updated dependencies [be48c5e] +- Updated dependencies [989aa48] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [3bc407d] +- Updated dependencies [76560a1] + - @objectstack/core@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index 233f231d564..af8ad1c78ad 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": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 1732ab1eaae..0e848860620 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,265 @@ # @objectstack/plugin-security +## 18.0.0-next.2 + +### Minor Changes + +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- 6f21216: feat(plugin-security,cli)!: `sys_user_permission_set.permission_set_id` is retired, the boot-time grant-name backfill is deleted, and a grant is unique per user, set and organization (ADR-0131 D4/D10) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + A permission-set grant names its set by `permission_set`, its one reference (ADR-0131 D4). The legacy id column is no longer declared, and the grant's uniqueness now keys the name. + + FROM → TO: + + - `sys_user_permission_set.permission_set_id` → `permission_set`, the machine name of the set the grant holds. A seed row or data-door write that still carries `permission_set_id` is refused with `400 INVALID_FIELD`. Fix: delete the key and name the set, `{ user_id, permission_set: '', organization_id }`. + - The unique index (`user_id`, `permission_set_id`, `organization_id`) and the (`permission_set_id`) index → one unique index on (`user_id`, `permission_set`, `organization_id`), declared `unique: 'organization'`: its organization key part is NULL-safe (ADR-0120 D3), so it refuses a second grant of one set to one user in one organization AND a second platform-wide grant (organization NULL), for every writer and every write path — batches, multi-row updates, concurrent writes and raw SQL included. The data doors answer `409 UNIQUE_VIOLATION` (the engine throws `DuplicateRecordError`, `DUPLICATE_RECORD`). Fix: keep the grant that exists; to change it, update that row. + - The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → the v18 ceremony: plan it with `os migrate organization-ownership --out plan.json`, review the file it writes, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then. + + On a 17.x database every grant names nothing until `--apply` names it, so between the first v18 boot and `--apply` no grant confers anything, the platform administrator's included. + + What else changes: + + - The plan run, `os migrate organization-ownership --out plan.json`, reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL): + - `duplicates`: groups of named grants holding one set for one user in one organization. `--apply`'s `column-drops` step does not drop `permission_set_id` while one group remains. + - `rewriteCollisions`: legacy grants whose derived name their user already holds in the same organization. `--apply`'s rewrite names none of them and blocks, naming both ids. + - On a database without the `permission_set` column it counts zero and names the remedy (boot this release once), instead of refusing the plan. + + The ceremony deletes no grant: remove the one you do not keep, then re-run. + - The organization-administrator reconcile and its boot sweep write no grant for a pair holding an unnamed grant in that organization: the ceremony's rewrite names that grant, and a named grant beside it would hold the key the rename needs. + - `--apply` drops the two retired indexes before the column, in a separate earlier schema-drift apply: MySQL refuses to drop a column a key still uses. + - The organization-administrator reconcile and its boot sweep, the platform-admin bootstrap's unnamed-grant leg (Leg C) and the catalog reference report read grants by name only. A legacy grant that names nothing confers nothing, still withholds the platform-admin promotion while it is unscoped, and is reached by no revocation until the ceremony's rewrite names it; once named, the boot sweep revokes it where its pair no longer qualifies. The report's `UnnamedGrant` entries lose `permissionSetId`. + - `GET /meta/object/sys_user_permission_set` no longer serves the hidden `permission_set_id` field. +- be48c5e: feat(spec)!: retire the delegated-admin scope's `manageBindings` and `authorEnvironmentSets` — rights no door honoured (#23488) + + **BREAKING** — `manageBindings` and `authorEnvironmentSets` on a permission set's `adminScope` (`AdminScopeSchema`, ADR-0090 D12) are now refused at parse. They named the right to bind sets to positions and the right to author environment permission sets, and they gated the `sys_position_permission_set` and `sys_permission_set` rows, which ADR-0131 D3 retired. A position's sets are part of its definition and a permission set is a definition; both are written through the metadata door, which admits a `manage_metadata` holder and reads no `adminScope`. So a delegate who "may bind" or "may author" was granted nothing. Delete both keys: a delegate's scope is placement, assignment and the allowlist. + + Clause-②: yes (narrowing) + + Measured before removal, at `cdc42eb49`: no production author set either key to `true` (the showcase wrote the default `false`; the control key `manageAssignments: true` has four production authors), and no door that writes a position or a permission set consulted either key. Their one reader was the delegated-admin gate's strict-containment comparison, which changes below. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `adminScope: { …, manageBindings: true \| false }` | nothing — delete the key | + | `adminScope: { …, authorEnvironmentSets: true \| false }` | nothing — delete the key | + | a delegate who re-composes what a position distributes, or authors a set's body | a `manage_metadata` holder writes the position or set definition through the metadata door (ADR-0131 D3); the delegate assigns people to positions and grants allowlisted sets inside its subtree | + | reading `scopes[].manageBindings` / `scopes[].authorEnvironmentSets` from `GET /api/v1/security/my-delegable-scope` (`DelegableAdminScope`) | nothing — the report carries `manageAssignments` as the scope's one action right | + + **The one-line fix:** delete `manageBindings` and `authorEnvironmentSets` from every `adminScope`. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`AdminScope` types both keys as retired), and the parse — `definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name` — refuses it at its own path with the prescription: + + > `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The containment change (`@objectstack/plugin-security`) + + The delegated-admin gate judges a delegate who hands out a set that itself carries an `adminScope` — a `sys_user_position` assignment of a position that distributes it, or a direct `sys_user_permission_set` grant — by **strict containment**. That comparison read both retired flags. It now reads `includeSubtree`, `manageAssignments` and the allowlist only: + + - a delegate whose own scope set either flag `true` can **no longer** hand out a scope otherwise EQUAL to its own — the inert flag used to count as the strictly-larger axis and let that lateral copy through the equal-scope refusal. It is refused `403 PERMISSION_DENIED` (`… would hand out an adminScope not strictly contained by yours`); + - a granted scope that set either flag `true` no longer counts as out-reaching a delegate that did not. + + `DelegatedAdminGate.describeDelegableScope()` (and so `GET /api/v1/security/my-delegable-scope`) no longer reports either flag. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `AdminScopeSchema`, a `strictObject` — so each refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/AdminScope:manageBindings`, `security/AdminScope:authorEnvironmentSets`. `DelegableAdminScope` (`@objectstack/spec/contracts`) drops both fields. + - **The D2 conversion `permission-admin-scope-binding-authoring-removed`** (protocol 18, retired from the load path) deletes both keys from every `permissions[].adminScope`, one notice per key, whatever they hold. Both keys carried the default `false`, so a 17.x parse materialized them in every declared scope; the rehydration seams replay this conversion over stored `permission` rows and built artifacts, and `os migrate meta --stored` lists the rows that carry them before the upgrade. + - **The D3 entry `permission-admin-scope-binding-authoring-retired`** records the containment change above: the judgement the strip cannot make for the upgrader. + - **`@objectstack/core`:** `permissionSetDefinitionFromRow` — the mapping `os migrate`'s legacy-row ceremony saves a `sys_permission_set` row through — drops both keys from the row's `admin_scope`. That save goes through `saveMetaItem`, which replays no conversion: measured, a 17.x row's body answered `422 INVALID_METADATA` at `adminScope.manageBindings`, and the same body without the keys saved. + - **`@objectstack/platform-objects`:** the permission-set form's `adminScope` help text (all four locales) names the scope's live keys only. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's two switches for these keys retire in an objectui follow-up. + + + +### Patch Changes + +- 0d3ea29: fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + + Clause-②: no + + Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + + - **`@objectstack/plugin-security`.** + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. + - **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. + - **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. + - **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. + - **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. +- 301eec6: feat(spec)!: retire the object's `externalSharingModel` — the external org-wide default no principal ever read (#23268) + + **BREAKING** — `externalSharingModel` on an object (`ObjectSchema`, the `object` metadata type) is now refused at parse. ADR-0090 D11 declared it as a second org-wide default for portal and partner principals, and nothing ever evaluated it: no request resolves an external principal, and the authorization kernel's `EXTERNAL` rung (ADR-0095 / ADR-0099) admits explicitly shared rows only, never an org-wide default. Setting `externalSharingModel: 'private'` hid nothing from anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share (a manual share or a sharing rule), and an anonymous reader through a share link (`publicSharing`, unchanged). + + Clause-②: no (narrowing) + + Measured before removal, at `e4ec6ec1a`: no row-pipeline reader in any package. The key's only readers were authoring checks of its own ordering against `sharingModel` — the publish linter's alias and wider-than-internal branches, and the `plugin-security` posture gate's env-tighten comparison — plus the Studio console's object settings select (a separate console change). The only authored instances in this repository were two showcase objects. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `externalSharingModel: 'private'` (or `'public_read'`, `'public_read_write'`, `'controlled_by_parent'`) on an object | nothing — delete the key; share rows explicitly where an outside principal needs them | + | `import { SECURITY_EXTERNAL_WIDER } from '@objectstack/lint'` | nothing — the rule `security-external-wider-than-internal` is removed with the key it judged | + + **The one-line fix:** delete `externalSharingModel` from every object. + + **What an author who still writes it sees.** `tsc` fails at the authoring site (`ObjectSchema` types the key as retired), and the parse — `ObjectSchema.create()`, `defineStack({ objects })`, `PUT /api/v1/meta/object/:name` (draft saves included) — refuses it at its own path with the prescription: + + > `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The retirement kit + + - **A `retiredKey()` tombstone** on `ObjectSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `data/Object:externalSharingModel`. + - **The D2 conversion `object-external-sharing-model-removed`** (protocol 18, retired from the load path) deletes the key from every `objects[]` entry, one notice per object, as a lossless delete. Studio's object settings offered the select and `PUT /api/v1/meta/object` persisted it until this release, so a stored `object` row can carry it; the rehydration seams replay this conversion over stored rows, and `os migrate meta --stored` lists the rows that carry it before the upgrade. + - **The D3 entry `object-external-sharing-model-retired`** asks the author whether anything they built assumed the key kept outside users away from an object's rows. Nothing ever enforced that. + - **Ledger:** the row goes `planned` → `dead`, kept as a tombstone row (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/lint`:** the `security-external-wider-than-internal` rule and its exported `SECURITY_EXTERNAL_WIDER` id are removed, and `security-owd-alias` no longer reads `externalSharingModel`; the parse refuses the key on every parsed door. On an unparsed intake (`os lint` on a raw config) the ledger-driven advisory reports it as `liveness-dead-property`. + + **`@objectstack/plugin-security`:** the object posture gate's env-tighten-only rule (R1) compares `sharingModel` alone; the external half left with the key. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- b1e9280: fix(plugin-security, organizations): the seed-ownership claims hand over only the rows the seed wrote, and a guest-written or public-form row keeps no owner (ADR-0138 D1) + + The claim that hands seeded records to the platform admin runs on every `app:seeded`, so on every boot of an app with seed data. Its NULL-owner arm re-owned every unowned row of every eligible object, and no stored column tells a seed row from a guest-written row or a public form's intake. Those rows keep no owner on purpose (ADR-0138 D1: "the owner stays unset"), and the claim handed them to the platform admin at the next boot. + + - **`claimSeedOwnership`** (`@objectstack/plugin-security`) re-owns a NULL-owner row only when the seed reported landing on it: `owner_id IS NULL AND id IN (seeded ids)`, in chunks of half the per-row hook ceiling, read through the `seed-settlement` contract's `seededRecordIds`. The settle pass (`app:seeded`) and the first-admin promotion (`bootstrapPlatformAdmin`, new option `seededRecordIds`) both pass the reading. A replay reports the rows an earlier boot wrote, so a seed row is still claimed on a later boot. The `usr_system` arm is unchanged: only a seed or a legacy install writes that owner. + - **`claimOrgSeedOwnership`** (`@objectstack/organizations`) takes the same selector, read for the organization the owner bind claims for: the rows that organization's seed replay landed on, which ran in the same call that created it. Each write carries at most half the per-row hook ceiling's worth of ids, so none is refused for its size and the claim no longer reads pages. + - A row a hook, a flow or an assignment rule gave an owner keeps it, as before. + + **Direct callers.** Called without `seededRecordIds`, `claimSeedOwnership` re-owns only `usr_system`-owned rows and its pass report says no seed reading was published; `claimOrgSeedOwnership` re-owns nothing and says so. The deprecated `ensureDefaultOrganization` wrapper in `@objectstack/organizations` holds no kernel, so it hands over no row; use `OrganizationsPlugin`, which binds the reading. + + **Outside the claim's purpose** (handing the boot seed to the first admin), and so no longer re-owned by a later boot's pass: rows written by seed writers that emit no `app:seeded` — a package install's or a publish's seed apply, the dev hot-reload seeder, and the marketplace install's seed. A dataset they share with the boot bundle is re-reported by the next boot's replay and claimed then. +- 0d784da: fix(cli,plugin-security): `os migrate organization-ownership --apply` resolves the built-in positions and the `everyone` baseline, so a 17.x database's seeded `everyone` binding no longer blocks the ceremony + + Clause-②: no + + - **What was wrong.** The ceremony's boot composes the security plugin for its declarations only: `init()` runs, and `start()` and every lifecycle hook do not. The plugin declared the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) in `start()`, and the `everyone` baseline at `kernel:ready`. So the ceremony's catalog held neither. Every 17.x environment database carries a seeded binding from `everyone` to `member_default`. On every such database, `authored-conversion` stopped with "Binding rows give position everyone the sets member_default, and the catalog holds no position of that name to carry them". With `OS_MULTI_ORG_ENABLED=true`, the rewrite gate stopped on the same binding ("deleting them would lose a grant"). + - **What changed.** `SecurityPlugin.init()` now declares the six built-in positions. It also declares `everyone` naming the deployment baseline: the app's `isDefault` set composed with `member_default`. These are registry declarations only, and nothing is read from or written to the database. The ceremony's catalog now resolves `everyone` with `member_default`, as a started kernel does, so that binding converts as declared. A binding that names a position no catalog declares still blocks. + - **The baseline in `init()` is the fail-closed reading.** The high-privilege check runs with no declared-capability context there, because the stack's declarations are still arriving while plugins initialize. So a shipped baseline set whose system permission only an app's capability declaration excuses is not named in `init()`. The `everyone` derivation at `kernel:ready` is unchanged. It runs the check against every declared capability, adds the package defaults a `manage_metadata` holder accepted, and replaces the `init()` declaration. + - **What a started kernel sees.** Its catalog after boot is the same. Three things move earlier or stay put: + - the six positions, and `everyone`'s baseline, are in the engine registry from the plugin's `init()` instead of from its `start()` and `kernel:ready`; + - if the `kernel:ready` derivation fails, `everyone` keeps the baseline from `init()`, where it used to name no set; + - the "the built-in positions … were NOT declared as position metadata" warning, for an engine that exposes no registry, now prints from `init()`. + - **Unchanged.** The cold boot's refusal of an environment-held name still skips the built-in names. A stored definition under a built-in name still shadows the declaration at read. The ceremony's `authored-conversion` and rewrite steps are unchanged. The ceremony still does not read the accepted package defaults: a binding to a set that only an acceptance puts on `everyone` still blocks. +- 23e689d: fix(plugin-security,spec): the unnamed-grant boot line and the `ups-permission-set-id-column-retired` upgrade entry name the reviewed-plan ceremony, `--out plan.json` then `--apply --backup-taken --plan-file plan.json`, instead of a bare `--apply` the CLI refuses (ADR-0131 D10) + + Clause-②: no + + `os migrate organization-ownership --apply` refuses a run without `--plan-file`, the file a plan run wrote and the operator reviewed. Two remedy texts still told the operator to run `--apply` alone, which answers `plan-file-required` and writes nothing: + + - `@objectstack/plugin-security`: the `kernel:bootstrapped` catalog reference report's error line for grants that name no permission set (`permission_set` empty). + - `@objectstack/spec`: the `replacement` of the ADR-0087 entry `ups-permission-set-id-column-retired`, the row the upgrade guide prints for the retired `sys_user_permission_set.permission_set_id`. + + Both now name the two steps: plan first, `os migrate organization-ownership --out plan.json`, review the file it writes, then `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, which executes that plan; restart afterwards. The ceremony itself is unchanged: its rewrite names each legacy grant from the retired id, and its column-drops step drops the two retired indexes and then the column. Only the text changes; the line's level, its fields and the entry's `id` are as they were. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 4d5e42cec7a..640e60427a9 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index 21488a576d8..e2ee4da1fc2 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,159 @@ # @objectstack/plugin-sharing +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. + +### Patch Changes + +- c245986: fix(plugin-sharing): a record-share grant refused for a non-user recipient names the sharing-rule recipient that delivers it, so a `group` caller is sent to `team` + + Clause-②: no + + **What was wrong.** `ISharingService.grant` writes a record share for a `user` only (ADR-0078), and refuses `group`, `position` and `unit_and_subordinates` with one sentence. FROM: "VALIDATION_FAILED: recipientType must be 'user' (received "group") — group/position recipients are delivered via sharing rules (ADR-0111 D7)". The sharing-rule vocabulary has no `group` recipient: a rule spells it `team`, since the ADR-0090 D3 rename. A caller or an AI that followed the remedy wrote a rule naming `group`, which neither the authoring `sharedWith.type` enum nor the `sys_sharing_rule.recipient_type` select offers. A `unit_and_subordinates` caller read a sentence that did not name its value. + + **What changed.** Each declared value's remedy names its own rule recipient. TO: + + - `group`: "… — a group recipient is delivered via a sharing rule with recipient type 'team', its name since the ADR-0090 D3 rename (ADR-0111 D7)" + - `position`: "… — a position recipient is delivered via a sharing rule with recipient type 'position' (ADR-0111 D7)" + - `unit_and_subordinates`: "… — a unit_and_subordinates recipient is delivered via a sharing rule with recipient type 'unit_and_subordinates' (ADR-0111 D7)" + + A value no record share declares (an untyped REST body sending `team`, say) is told so, and is named no route: "… — "team" is not a record-share recipient type (ADR-0111 D7)". + + **Unchanged.** Which values `grant()` accepts (`user` only), the `VALIDATION_FAILED` code and its 400, the first clause of every refusal, the retired `guest` value's own prescription, and the record-share vocabulary. Nothing is written for a refused grant. +- c980d11: fix(plugin-sharing, runtime): a shared conversation's messages open for exactly the callers its share link opens for, on both mounts + + Clause-②: no — `/messages` now reaches the answer `/resolve` already gives the same caller for the same link, on the same mount; who can open a share link is unchanged. + + A share link to an AI conversation has two public routes: `/resolve` returns the conversation, and `/messages` returns its messages. Both check the link through the same service call. `/resolve` passed it every input an audience is judged by. `/messages` passed fewer, so it refused callers `/resolve` served: + + - **A `signed_in` link, a signed-in caller:** plugin-sharing's share-link routes answered `/messages` with `404 NOT_FOUND`, so the console's shared-conversation page showed the conversation with no messages. It now returns the messages, as the runtime dispatcher's share-links domain already did. + - **An `email` link, an allowlisted `?email=`:** both mounts answered `/messages` with `404 NOT_FOUND`. Both now read `?email=` on `/messages` exactly as on `/resolve`, and return the messages. + - **Unchanged:** every caller `/resolve` refuses is still refused on `/messages` with `404 NOT_FOUND`: an anonymous caller of a `signed_in` link, and a caller of an `email` link with no email or an off-list email, signed in or not. A password-protected link still needs its password on both routes. `/resolve` is unchanged. +- 0ecac96: fix(plugin-sharing, runtime): a share link limited to an email allowlist answers every caller it does not open for with `404 INVALID_OR_EXPIRED`, whatever email and password were sent + + Clause-②: no — every request refused before is still refused, and every request served before is still served; only which refusal code a caller reads changes. + + A share link can carry a password and the `email` audience together. For such a link, the share-link resolve door answered a caller it did not open for from the password, never the audience: `401 NEEDS_PASSWORD` with no password, and `401 WRONG_PASSWORD` with any password, the correct one included, when the email sent was missing or not on the allowlist. A caller with the right password read "Incorrect password". + + - **An `email` link, any caller it does not open for:** `404 INVALID_OR_EXPIRED`, with or without an email, with no password, a wrong one or the correct one, signed in or not. It is the answer a password-less `email` link already gave and an unknown token gives, so it tells the caller neither whether the email is on the allowlist, nor whether the password is right, nor whether the link has a password. The door does not read the email to choose its answer. + - **Served, unchanged:** an allowlisted email with the correct password (or, on a link without a password, an allowlisted email alone) still opens the record. An API or SDK caller sends both together; the door no longer prompts for the password first. + - **Unchanged:** links with any other audience, including the `signed_in` order (`401 SIGN_IN_REQUIRED` before sign-in, the password arms after), a `public` link's `NEEDS_PASSWORD` / `WRONG_PASSWORD`, and the expired-or-revoked `410 EXPIRED_OR_REVOKED`. + - Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain. +- d1e2841: fix(plugin-sharing): an email-audience share link's refusal does the same work whatever email and password are presented + + The refusal of an `email`-audience share link no longer does a different amount + of work depending on the caller's input, as its answer bytes already did not. + The refusal and the served response are byte-for-byte unchanged. + + Clause-②: no +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [989aa48] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [76560a1] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 24d57f770f3..f49cd8f7ad0 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index b6ceb1944c4..27399df5263 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,57 @@ # @objectstack/plugin-webhooks +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/service-messaging@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 722b433a234..500cd8f01a8 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.", "type": "module", diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md index 6002d62cdb3..8b66fb53e33 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,118 @@ # @objectstack/dogfood +## 0.0.48-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [584e737] +- Updated dependencies [be16feb] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [94924e0] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [c245986] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [fc1a55a] +- Updated dependencies [b1e9280] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [d1e2841] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [b1cfa3d] +- Updated dependencies [23e689d] +- Updated dependencies [0fb55fc] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [a6d0715] +- Updated dependencies [c2bb302] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [9f81260] +- Updated dependencies [e611e2b] +- Updated dependencies [b513343] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [7e6e4e1] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [69351e9] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/plugin-security@18.0.0-next.2 + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/verify@18.0.0-next.2 + - @objectstack/plugin-audit@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + - @objectstack/plugin-sharing@18.0.0-next.2 + - @objectstack/service-datasource@18.0.0-next.2 + - @objectstack/service-storage@18.0.0-next.2 + - @objectstack/plugin-email@18.0.0-next.2 + - @objectstack/mcp@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/plugin-approvals@18.0.0-next.2 + - @objectstack/plugin-webhooks@18.0.0-next.2 + - @objectstack/service-messaging@18.0.0-next.2 + - @objectstack/example-crm@4.0.100-next.2 + - @objectstack/example-multi-package@0.0.7-next.2 + - @objectstack/example-showcase@0.3.22-next.2 + - @objectstack/connector-mcp@18.0.0-next.2 + - @objectstack/connector-openapi@18.0.0-next.2 + - @objectstack/connector-rest@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/service-analytics@18.0.0-next.2 + - @objectstack/trigger-record-change@18.0.0-next.2 + - @objectstack/trigger-schedule@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + - @objectstack/plugin-pinyin-search@18.0.0-next.2 + ## 0.0.48-next.1 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index a782dff964d..38da7933f7c 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.48-next.1", + "version": "0.0.48-next.2", "private": true, "license": "Apache-2.0", "description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", diff --git a/packages/qa/downstream-contract/CHANGELOG.md b/packages/qa/downstream-contract/CHANGELOG.md index 4dc5d24cc92..d035869c9af 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/downstream-contract +## 0.0.46-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 0.0.46-next.1 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index d232017a5a4..51001ce5293 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.46-next.1", + "version": "0.0.46-next.2", "description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).", "license": "Apache-2.0", "private": true, diff --git a/packages/qa/http-conformance/CHANGELOG.md b/packages/qa/http-conformance/CHANGELOG.md index a30783e213f..93b032dbe0f 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,19 @@ # @objectstack/http-conformance +## 0.1.8-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [1d79556] +- Updated dependencies [daafae6] +- Updated dependencies [be48c5e] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] + - @objectstack/core@18.0.0-next.2 + ## 0.1.8-next.1 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 60d37aab584..c54c85939bd 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.8-next.1", + "version": "0.1.8-next.2", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index 471c128d87d..6ab6c3babbc 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,273 @@ # @objectstack/rest +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- cac0be8: fix(rest,runtime)!: both `/data` write doors validate the request as the caller sent it: a `PATCH` or `POST` create whose body is not a record, and a `PATCH` whose `expectedVersion` is a number, are refused `400 VALIDATION_FAILED` on both doors before the engine is asked + + `RestServer` validates `PATCH /data/:object/:id` against `UpdateDataRequestSchema` and `POST /data/:object` against `CreateDataRequestSchema`. The runtime dispatcher's `/data` domain, the only answer on a host that mounts just the `@objectstack/hono` catch-all, validated neither, so a body that is not a record reached the engine. On a `PATCH` it answered `500` with the engine's own error. On a `POST`, an array created one row per element and a number created an empty row, both answered `201`. `RestServer`'s own `PATCH` gate parsed a coerced copy of the request: it read a JSON `null` body as `{}`, and it read a numeric `expectedVersion` as its string. Both doors now validate the assembled request as the caller sent it, and they answer each case alike. + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + + - `RestServer`, `PATCH /data/:object/:id` with a JSON `null` body: FROM `200`, an update that changed no field and still moved the record's `updated_at` (so every other holder of the old version saw a conflict on its next guarded save), TO `400 VALIDATION_FAILED` naming `data`, the record unchanged. The runtime dispatcher answers the same. + - The runtime dispatcher, `PATCH /data/:object/:id` with an array, a string, a number or `null` as the body: FROM `500` carrying the engine's own error text, nothing written, TO `400 VALIDATION_FAILED` naming `data`, before the engine is asked. `RestServer` already answered `400` for the first three. + - The runtime dispatcher, `POST /data/:object` with an array, a string or a number as the body: FROM `201` with one row created per array element, `201` with an empty row for a number, and the engine's own `400 INVALID_FIELD` for a string, TO `400 VALIDATION_FAILED` naming `data`, with no row created. `RestServer` already answered this. + - Both doors, `PATCH /data/:object/:id` with a body `expectedVersion` that is a number (`5`, `0`): FROM judged as its string (`'5'`) and answered `409 CONCURRENT_UPDATE`, TO `400 VALIDATION_FAILED` before the protocol is asked, whatever `If-Match` is sent beside it. The request schema declares the token a string, and a number is now one of the token shapes that can never carry a version. + + How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields, object }`, the envelope its request gate already used. The runtime dispatcher returns its own `400 VALIDATION_FAILED` envelope, `fields` under `error.details`, instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. + + Unchanged: a record body, `{}` included; a string `expectedVersion` (judged; `409` on a mismatch); and `If-Match` and the `DELETE`'s `?expectedVersion`, which are strings by transport. A JSON `null` body on a `POST` create is refused too, on both doors, in the companion entry for the `/data` create and query requests. + + To send a patch or a row, send the fields as a JSON object. To guard a write, send the version you read (the record's `updated_at`) as a string. The `@objectstack/client` SDK's `data.update` sends the version as an `If-Match` header string, so an SDK caller is affected only when the `data` it passes is not an object, or carries a number `expectedVersion`. +- 1f3ac4f: fix(rest,runtime)!: a `/data` `PATCH` or `DELETE` whose version token can never carry a version (`expectedVersion: ''` or `false`, `?expectedVersion=`, an empty `If-Match`) is refused `400 VALIDATION_FAILED` on both doors, instead of being applied unguarded beside a stale `If-Match` + + Both doors of `PATCH /data/:object/:id` and `DELETE /data/:object/:id`, `RestServer` and the runtime dispatcher's `/data` domain, read an optimistic-concurrency token from the body's `expectedVersion` (`PATCH`) or `?expectedVersion` (`DELETE`), else from `If-Match`. They forwarded it to the protocol only when it was truthy. `''` and `false` are not nullish, so a stale `If-Match` beside them was never read, and they are not truthy, so no token was forwarded: the write ran unguarded and answered `200`. The protocol already refuses the quoted-empty tag `""` with `400 VALIDATION_FAILED`, under the ruling that a token shape that can never carry a version is refused, neither read as "no token" nor answered `409`. It never saw these tokens, because the door dropped them first, and it reads a blank string as no token. Both doors now judge every token a caller sends, or refuse it before the protocol is asked, and they answer each token alike. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, on both doors, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + + - FROM a `PATCH` whose body `expectedVersion` is `''`, a blank string, `false` or an empty array, or a `DELETE` whose `?expectedVersion` is empty or blank, which wrote or deleted the record unguarded and answered `200` even beside a stale `If-Match`, TO `400 VALIDATION_FAILED` with the record unchanged, before the protocol is asked. + - FROM an empty `If-Match` sent with no body or query token, which wrote or deleted unguarded and answered `200`, TO the same `400 VALIDATION_FAILED`. An `If-Match` that is sent asks for a guard, and an empty one carries nothing to judge. A request that sends no `If-Match` at all is unchanged. + - FROM a body `expectedVersion` of `true`, an object or a non-empty array, which was judged as its string (`'true'`, `'[object Object]'`, the comma-joined elements), TO the same `400 VALIDATION_FAILED`. + - FROM a body `expectedVersion` of `0`, which was dropped like `''` and so wrote unguarded, TO a token judged as `'0'`, as every other number already was. On a real record that is `409 CONCURRENT_UPDATE`. + + How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields: [], object }`, the envelope it already gives the protocol's `""` refusal. The dispatcher returns its own `400 VALIDATION_FAILED` envelope instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. That is unlike the refusals the protocol throws out of `dispatch()`, `409 CONCURRENT_UPDATE` and the `400` for `""`, which that host still renders as `500` (the record is unchanged), because its catch-all reads only an error's `statusCode`. Rendering their declared status is a separate fix. + + Unchanged: a request with no token, a body `expectedVersion: null` (it is no token, so `If-Match` is read), a non-empty string or a non-zero number token (judged; `409` on a mismatch), and `""` (the protocol's own `400`). The body key is still stripped from the patch whatever its value. + + To write over a version you read, send that version (the record's `updated_at`) as the token. To write unguarded, omit both `expectedVersion` and `If-Match` rather than sending either one empty. The `@objectstack/client` SDK's `data.update` and `data.delete` already omit `If-Match` when `ifMatch` is empty, so an SDK caller is affected only when its `data` carries an `expectedVersion` that can never carry a version. +- c263588: fix(rest,runtime)!: the authoring-trail doors (`/meta` history, audit and diff, and package commits) stop admitting `setup.access`, so an organization admin is refused the environment's authoring trail + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Organization admins hold `setup.access`. Under a wall (the `isolated` and `group` postures), these four doors handed every organization's admin the environment-wide authoring trail of items no organization authors (ADR-0131 C5): who saved each item and when, its superseded bodies, and every package's commit timeline. The data door on the same three tables (`sys_metadata_history`, `sys_metadata_audit`, `sys_metadata_commit`) refuses them (`requiredPermissions: ['studio.access']`, ADR-0131 D7), and the doors now follow the same rule. The narrowing applies in every posture, `single` included. + + Each door, and the callers it now refuses: + + - **BREAKING** `GET /meta/:type/:name/history` (`@objectstack/rest`), for a caller holding `setup.access` and neither `studio.access` nor `manage_metadata`: was 200 with the item's version history; now `403 FORBIDDEN`. + - **BREAKING** `GET /meta/:type/:name/audit` (`@objectstack/rest`), for the same caller: was 200 with the item's audit rows; now `403 FORBIDDEN`. + - **BREAKING** `GET /meta/:type/:name/diff` (`@objectstack/rest`), for the same caller: was 200 with a diff of the item's stored versions; now `403 FORBIDDEN`. + - **BREAKING** `GET /packages/:id/commits` (`@objectstack/runtime`), for a caller holding `setup.access` without `studio.access`, **with or without `manage_metadata`**: was 200 with the package's commit timeline; now `403 PERMISSION_DENIED`. + - `manage_metadata` never admitted this door. A caller holding it alone was refused before and still is; a caller holding it beside `setup.access` was admitted only through `setup.access`. + - One such caller is a walled organization owner granted `manage_metadata` inside its own organization. It keeps history, audit and diff, and loses commits. + + Who is still admitted: + + - History, audit and diff admit a system caller, `studio.access` or `manage_metadata`, through their own predicate. + - The commits door admits a system caller or `studio.access`. + - Both capabilities are platform-scoped. Platform operators are not affected, and neither are metadata authors who hold `studio.access` (all four doors) or `manage_metadata` (history, audit and diff; commits never admitted it). + + Unchanged: + + - The draft switches keep their admission, `setup.access` included: `GET /meta/_drafts`, `?state=draft` and `?preview=draft`. + - `GET /packages`, `GET /packages/:id` and `GET /packages/:id/export` keep their read cohort (`studio.access` or `setup.access`). + - The refusal keeps its envelope and code at each door. Only the message changes: it now names the capabilities that admit the door. + + What changes for you. If an organization admin needs a metadata item's history, audit trail or package commits, grant `studio.access`. A `manage_metadata` holder without `studio.access` who read package commits through `setup.access` needs `studio.access` for them too. A client that offers History or Audit to a caller holding only `setup.access` should hide them for that caller. The Setup app's item editors do this in objectstack-ai/objectui#12475. +- 7c6b7f4: fix(rest,runtime)!: a JSON `null` body on a `/data` create or query is refused `400 VALIDATION_FAILED` on both doors; the runtime dispatcher's `POST /data/:object/query` validates `FindDataRequestSchema`; and the dispatcher asks an object's API exposure before any request gate + + `RestServer` read a JSON `null` body on `POST /data/:object` as an empty record and created an empty row, and on `POST /data/:object/query` as no query and answered the full record set. The runtime dispatcher's `/data` domain, the only answer on a host that mounts just the `@objectstack/hono` catch-all, did the same for the create, and validated no query at all: a mistyped clause or a retired key reached the engine, which dropped it and answered the full record set. `CreateDataRequestSchema.data` declares a record and `FindDataRequestSchema.query` declares a QueryAST or nothing, never `null`. Both doors now validate the assembled request as the caller sent it, forward that same request, and answer each case alike. On an object whose API exposure denies the verb, the dispatcher used to answer a `PATCH` body that is not a record, or a create body that is an array, a string or a number, with its request gate's `400`, where `RestServer` answered the exposure refusal; it now asks the exposure gate before each request gate, as `RestServer` does. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + + - Both doors, `POST /data/:object` with a JSON `null` body: FROM `201` and an empty row created, TO `400 VALIDATION_FAILED` naming `data`, with no row created. + - Both doors, `POST /data/:object/query` with a JSON `null` body: FROM `200` with the full record set, TO `400 VALIDATION_FAILED` naming `query`, the engine never asked. + - The runtime dispatcher, `POST /data/:object/query` with a mistyped clause (`{ "limit": "ten" }`), the retired `joins` key, or a number as the body: FROM `200` with the full record set, the clause dropped, TO `400 VALIDATION_FAILED` naming the member (`query.limit`, `query.joins`, `query`). `RestServer` already answered this. + - The runtime dispatcher, `POST /data/:object/query` with a string or an array as the body: FROM the engine's `400 INVALID_FIELD` for numbered field names, TO `400 VALIDATION_FAILED` naming `query`. `RestServer` already answered this. + - The runtime dispatcher, `POST /data/:object/query` with a body that nests the query under a `query` key (`{ "query": { "where": … } }`): FROM read as the nested query (`200`), TO read as `RestServer` reads it, the body itself being the query, so `query` is a field the object does not have: `400 INVALID_FIELD`. + - The runtime dispatcher, `PATCH /data/:object/:id` with a body that is not a record (an array, a string, a number or `null`), and `POST /data/:object` with an array, a string or a number as the body, on an object whose API exposure denies the verb: FROM `400 VALIDATION_FAILED`, TO the exposure refusal `RestServer` answers (`404` when the object sets `apiEnabled: false`, `405` when its `apiMethods` leaves the verb out). The request was refused before and is refused now; only the answer changes. A JSON `null` create on such an object already answered the exposure refusal (the old gate read it as `{}`), and so did every query on it: the query route had no request gate, and its new one runs after the exposure gate, as `RestServer` orders it. + + How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields, object }`, the envelope its request gates already use. The runtime dispatcher returns its own `400 VALIDATION_FAILED` envelope, `fields` under `error.details`, instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. + + Unchanged: an empty request body, which every host hands over as `{}` (an empty record creates a row; an empty query answers every record); a record body; a conforming query; and a body `object` key on a query, which the path object still overrides. + + To create a row, send its fields as a JSON object, `{}` for all defaults. To query, send the QueryAST itself as the body, `{}` for every record. The `@objectstack/client` SDK's `data.create` and `data.query` send exactly these, so an SDK caller is affected only when the value it passes is `null`, or not the shape the request declares. + +### Patch Changes + +- 87d0619: feat(spec): the app item read answers by package id. `GET /meta/app/` returns the package's app, or the same 403 / 404 the read by name gives (ADR-0048 §3.1) + + Clause-②: yes (widening) + + ADR-0048 §3.1 makes an installed package's id the route key of its UI. With one app per package the app is the package container, so the console opens an app at `/apps/`. The app item read now accepts that key too. + + - **`GetMetaItemRequestSchema.name`** declares it. For `type: 'app'` only, a key in the package-id grammar (`MANIFEST_ID_PATTERN`, at least one dot) names the package. App names are snake_case with no dot, so the two grammars never overlap and every read by name is unchanged. The new export `isAppPackageIdKey(type, name)` is the one test for the arm. + - **`getMetaItem`** (`@objectstack/metadata-protocol`) resolves it to the package's one app, as the package-scoped list (`GET /meta/app?package=`) serves it, read by its own name in that package. The response's `name` is the app's name. Both hosts' item reads (`RestServer` and the `@objectstack/hono` catch-all) answer it with no change of their own, and the per-caller read gate judges the app exactly as it does by name: + - a caller the app is withheld from gets `403 PERMISSION_DENIED`; + - an unknown package, a package with no app, a package whose app is unpublished, and a package holding more than one app all get the `404` absence answer, byte-identical to the one for an unknown app name. A package holding more than one app (ADR-0019 D3 allows at most one) is also reported once in the server log, naming its apps; + - a caller who holds the app is served it. + - A `packageId` (`?package=`) naming a different package than the key is refused with `400 INVALID_REQUEST`. + - `/meta/app//history`, `/audit` and `/diff` stay by name (`@objectstack/rest`): the package-id key belongs to the plain item read only. + + From a client: `client.meta.getItem('app', 'com.acme.crm')`. +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + - @objectstack/service-package@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 234ee1b1e0c..18b1c921fa8 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol", "type": "module", diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index d50319f0b6e..6addbd9e53d 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,502 @@ # @objectstack/runtime +## 18.0.0-next.2 + +### Minor Changes + +- cb9841a: fix(hono,runtime)!: `createHonoApp` hands the dispatcher a repeated query name as an array, so the dispatcher's arity rules refuse it instead of answering its last value; `/packages` refuses a repeated `keepData` or `overwrite` + + `@objectstack/hono`'s `createHonoApp` catch-all built the dispatcher's query by copying `URLSearchParams` into an object, so a name sent twice reached `HttpDispatcher.dispatch()` as its LAST value and the first was dropped without a word. Every arity rule the dispatcher declares was therefore unreachable on a `createHonoApp` host: `GET /meta/app/crm?package=a&package=b` was answered `200` for package `b`, where the same request through `@objectstack/plugin-hono-server` is refused. The catch-all now reads the query the way `@objectstack/plugin-hono-server` does: a name sent once is the string it always was, a repeated name is the array of its values, and the object is built without dynamic property writes, so a `__proto__` name is an ordinary own key. + + Two `/packages` write flags read a repeat as absent, and are now refused before the registry or the protocol is touched, as `enabled` and `version` already are. `DELETE /packages/:id?keepData=true&keepData=true` dropped the package's object tables, because the protocol keeps storage only for `keepData === true`, and was answered `200`. `POST /packages?overwrite=true&overwrite=true` answered `409` "already exists" for a package one `overwrite=true` replaces. Both doors were reached that way through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats, and would have been through `createHonoApp` from this change. + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you: send each single-valued query name once. On a `createHonoApp` host, a request that repeats one is now refused `400` in the dispatcher's envelope, `{ success: false, error: { code, message } }`, with the code and sentence the same request gets through `@objectstack/plugin-hono-server` (`VALIDATION_ERROR`, "The "NAME" query parameter was supplied N times…"; `INVALID_FILTER` on the `/data` filter slot), where it used to be answered with the last value. On every host, `DELETE /packages/:id` refuses a repeated `keepData` and `POST /packages` a repeated `overwrite`, with that same `VALIDATION_ERROR` and sentence. A name sent once is answered as before; genuinely multi-valued names such as `select` and `expand` now reach the dispatcher as the array they are. One malformed input moves the other way, to match `@objectstack/plugin-hono-server`: a query pair with an EMPTY name (`?=x`) is no longer handed to the dispatcher, so a door with a closed parameter set reads it as absent where it used to refuse it as an unknown parameter `""`. The `@objectstack/client` SDK sends each single-valued name once, and the Console's package delete sends `keepData` once. +- 584e737: A hook can now tell a child delete that a parent's cascade issues from a hand delete: `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear` + + Clause-②: yes + + When a record is deleted and a relation whose `deleteBehavior` resolves to `cascade` (a `master_detail` unless it declares `restrict`, or a `lookup` / `tree` that declares `cascade`) removes its children, the engine deletes each child as the calling user. The child's `beforeDelete` and `afterDelete` hooks therefore saw the same `ctx.user`, `ctx.session` and `ctx.input` as a user deleting that one child by hand. A guard that freezes settled records, such as a quote's lines once the quote is accepted, could refuse both deletes or neither, and refusing both made the parent, and every record whose cascade reaches it, undeletable. + + - **What the key means.** `ctx.referentialCascadeDelete === true` exactly on a delete the engine's cascade issued: the child delete, in both phases, at any depth (a grandchild that the child's own cascade removes is marked too). It is absent, never `false`, on every other delete: the delete the caller asked for, a user's hand delete of the same child, and any write a hook issues through `ctx.api` during a cascade, deletes included. The `set_null` cleanup update keeps `referentialFieldClear` and never carries this key, and a cascade child delete never carries `referentialFieldClear`. + - **The guard idiom.** Refuse a hand delete of a frozen line and yield to the cascade: + + ```ts + if (ctx.referentialCascadeDelete !== true && ctx.previous?.locked === true) { + throw new Error('This line belongs to a settled quote and cannot be deleted.'); + } + ``` + + - **Only the declared key is the contract.** There is no operation-private (`__`-prefixed) spelling of this fact on the execution context. The engine recognises the delete its cascade issued through an engine-private record keyed on that child's object and id, so nothing on `ctx.api`, `ctx.transaction` or any `__` key can stand in for it. Read `ctx.referentialCascadeDelete`. + - **In a sandboxed hook body.** The key is carried across the QuickJS boundary by contract, so a lowered `*.hook.ts` body reads `ctx.referentialCascadeDelete` exactly as an in-process handler does. + - **spec (minor):** `HookContextSchema` declares `referentialCascadeDelete` (boolean, optional). One optional engine-produced key on the non-strict runtime hook context; nothing previously valid changes meaning. + - **objectql (minor):** `delete()`'s hook-context assembly sets the key on the delete the cascade walk issues. The walk still hands the child the caller's context unchanged, so no other hook-context member moves. + - **runtime (minor):** `buildSandboxContext` and `installCtx` carry the key into the VM. +- 3d231ba: fix(runtime)!: the runtime dispatcher's `DELETE /data/:object/:id` honours `?expectedVersion` and `If-Match` as its `RestServer` twin does, so a delete carrying a stale version is refused instead of applied + + `RestServer`'s `DELETE /data/:object/:id` reads an optimistic-concurrency token from `?expectedVersion`, else from the `If-Match` header, refuses the query name supplied more than once, and hands the token to the protocol's `deleteData`, which refuses a delete whose record has moved past it. The runtime dispatcher's `/data` domain read neither, and its shared `callData` delete arm forwarded only the object and the id. So on a host whose `/data` routes the dispatcher serves (a `createHonoApp` host), a delete carrying a stale token deleted the record and answered `200`, the same answer a delete carrying no token gets. The domain now reads the token the way `RestServer` does and `callData` forwards it to `deleteData`. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, wherever the runtime dispatcher serves `DELETE /data/:object/:id`. Where `@objectstack/rest` is mounted it serves this route itself and already answered each of these, so the standalone server's answers do not move. + + - FROM a delete with a stale `?expectedVersion` or `If-Match` that deleted the record and answered `200`, TO a refusal with the record kept: the protocol's `409 CONCURRENT_UPDATE`, thrown out of `dispatch()` for the host's exit to render, as every other refusal `deleteData` throws is. A `@objectstack/hono` `createHonoApp` host still renders that refusal as `500` (the record is kept): its catch-all reads only an error's `statusCode`, and rendering the declared `409` is a separate fix. + - FROM a repeated `?expectedVersion` that was ignored (the record deleted, `200`), TO `400 VALIDATION_ERROR` in the dispatcher's envelope with the sentence `RestServer` answers ("The "expectedVersion" query parameter was supplied N times…"), before the protocol is asked. A one-element array is read as the one token it encodes. On a host whose adapter hands the dispatcher only the last value of a repeated query name (the `@objectstack/hono` `createHonoApp` catch-all, until that adapter keeps repeats), the last `?expectedVersion` is judged as the token. + - FROM a delete carrying a token on a host whose protocol has no `deleteData` (the ObjectQL fallback), which deleted unguarded, TO `501 NOT_IMPLEMENTED`, before anything is read or deleted. A delete with no token reaches the fallback as before. + + To delete over a version you read, send that version (the record's `updated_at`) once, as `?expectedVersion` or `If-Match`; `?expectedVersion` is judged when both are sent. A delete with no token is unchanged. The `@objectstack/client` SDK's `data.delete(object, id, { ifMatch })` sends `If-Match`, so an SDK caller that passes `ifMatch` now gets, through the dispatcher, the guard it asked for. +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- b1e9280: feat(spec): the seed loader reports the rows each dataset landed on, and the seed-settlement contract hands them to readers + + - **`SeedLoadResult.landedIds`** (`@objectstack/spec/data`, default `[]`): the ids of the stored rows a dataset's records landed on — a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none. A replay therefore reports the rows an earlier load wrote, so the answer holds across boots. The loader's own key and reference resolution is behind it: a dataset keyed on a reference reports the matched row, whose stored key is the resolved id, never the dataset's value. + - **`ISeedSettlementService.seededRecordIds?(object, organizationId?)`** (`@objectstack/spec/contracts`, optional): "which rows of this object did this process's seed write?". Without `organizationId` it answers for the boot-time seed sources (the inline seed `app:seeded` announces); a source publishes its rows before it settles, so a reader inside `app:seeded` already sees them. With `organizationId` it answers for that organization's per-organization replay; the host keeps the most recent replay only, so a process that creates many organizations does not hold every replay's rows. A host that does not implement it publishes no reading, and a reader treats every unowned row as one it cannot place. + - **`@objectstack/metadata-protocol`**: `SeedLoaderService.load()` fills `landedIds` on every per-dataset result. + - **`@objectstack/runtime`**: the `seed-settlement` service implements `seededRecordIds`. `AppPlugin` publishes the inline seed's rows before the source settles (the loader path and the basic-insert fallback alike), and its per-organization `seed-replayer` records each replay's rows under that organization. `SeedSourceHandle` gains `publishLanded(report)`. + + Nothing is removed or renamed. The reader this exists for is the seed-ownership claim, which now hands over only the seed's rows (see the `@objectstack/plugin-security` and `@objectstack/organizations` entries). +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- dd29bbe: feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`. + + FROM → TO: + + - A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply --plan-file FILE` with that plan file, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. + - `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table (the D7 tables and the `sys_metadata` family among them) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified. + - There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check. + + What does not change: + + - A fresh database boots: no table it creates carries a retired column, so no marker is needed. + - A database whose ceremony verified boots, whatever its columns. + - The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an `organization_id` column is refused, as above. + - A driver with no physical columns to read (memory, MongoDB) is not checked. + - A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. +- daafae6: fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call + + Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal. + + + + **BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not. + + - **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there. + - **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart. + - **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change. + - **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`. + - **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire. + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both. + - **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered). +- 650933e: fix(runtime)!: the runtime dispatcher's `PATCH /data/:object/:id` honours a body `expectedVersion` and `If-Match` as its `RestServer` twin does, so an update carrying a stale version is refused instead of applied + + `RestServer`'s `PATCH /data/:object/:id` reads an optimistic-concurrency token from the body's `expectedVersion`, else from the `If-Match` header. It strips `expectedVersion` out of the patch and hands the token to the protocol's `updateData`, which refuses an update whose record has moved past it. The runtime dispatcher's `/data` domain read neither and handed the whole body over as the patch, and its shared `callData` update arm forwarded no token. So on a host whose `/data` routes the dispatcher serves (a `createHonoApp` host), an update carrying a stale `If-Match` wrote the record and answered `200`, the same answer an update carrying no token gets. A body `expectedVersion` reached the engine as a field and was refused as an unknown one. The domain now reads the token the way `RestServer` does, and `callData` forwards it to `updateData`. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, wherever the runtime dispatcher serves `PATCH /data/:object/:id`. Where `@objectstack/rest` is mounted it serves this route itself and already answered each of these, so the standalone server's answers do not move. + + - FROM an update with a stale `If-Match` that wrote the record and answered `200`, TO a refusal with the record unchanged: the protocol's `409 CONCURRENT_UPDATE`, thrown out of `dispatch()` for the host's exit to render, as every other refusal `updateData` throws is. A `@objectstack/hono` `createHonoApp` host still renders that refusal as `500` (the record is unchanged): its catch-all reads only an error's `statusCode`, and rendering the declared `409` is a separate fix. + - FROM a body `expectedVersion` that reached the engine as a field and was refused as unknown (`Unknown field 'expectedVersion'`, nothing written), TO the update's version token. It is stripped from the patch and never written as a field. The update is applied when the token names the current version and refused as above when it is stale. A body `expectedVersion` is read before `If-Match`, in `RestServer`'s order. + - FROM an update carrying a token on a host whose protocol has no `updateData` (the ObjectQL fallback), which updated unguarded, TO `501 NOT_IMPLEMENTED`, before anything is read or written. An update with no token reaches the fallback as before. + + To update over a version you read, send that version (the record's `updated_at`) as the body's `expectedVersion` or as `If-Match`. An update with no token is unchanged. The `@objectstack/client` SDK's `data.update(object, id, data)` sends `data` as the body, so an SDK caller whose `data` carries `expectedVersion` now gets, through the dispatcher, the guard it asked for. +- cac0be8: fix(rest,runtime)!: both `/data` write doors validate the request as the caller sent it: a `PATCH` or `POST` create whose body is not a record, and a `PATCH` whose `expectedVersion` is a number, are refused `400 VALIDATION_FAILED` on both doors before the engine is asked + + `RestServer` validates `PATCH /data/:object/:id` against `UpdateDataRequestSchema` and `POST /data/:object` against `CreateDataRequestSchema`. The runtime dispatcher's `/data` domain, the only answer on a host that mounts just the `@objectstack/hono` catch-all, validated neither, so a body that is not a record reached the engine. On a `PATCH` it answered `500` with the engine's own error. On a `POST`, an array created one row per element and a number created an empty row, both answered `201`. `RestServer`'s own `PATCH` gate parsed a coerced copy of the request: it read a JSON `null` body as `{}`, and it read a numeric `expectedVersion` as its string. Both doors now validate the assembled request as the caller sent it, and they answer each case alike. + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + + - `RestServer`, `PATCH /data/:object/:id` with a JSON `null` body: FROM `200`, an update that changed no field and still moved the record's `updated_at` (so every other holder of the old version saw a conflict on its next guarded save), TO `400 VALIDATION_FAILED` naming `data`, the record unchanged. The runtime dispatcher answers the same. + - The runtime dispatcher, `PATCH /data/:object/:id` with an array, a string, a number or `null` as the body: FROM `500` carrying the engine's own error text, nothing written, TO `400 VALIDATION_FAILED` naming `data`, before the engine is asked. `RestServer` already answered `400` for the first three. + - The runtime dispatcher, `POST /data/:object` with an array, a string or a number as the body: FROM `201` with one row created per array element, `201` with an empty row for a number, and the engine's own `400 INVALID_FIELD` for a string, TO `400 VALIDATION_FAILED` naming `data`, with no row created. `RestServer` already answered this. + - Both doors, `PATCH /data/:object/:id` with a body `expectedVersion` that is a number (`5`, `0`): FROM judged as its string (`'5'`) and answered `409 CONCURRENT_UPDATE`, TO `400 VALIDATION_FAILED` before the protocol is asked, whatever `If-Match` is sent beside it. The request schema declares the token a string, and a number is now one of the token shapes that can never carry a version. + + How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields, object }`, the envelope its request gate already used. The runtime dispatcher returns its own `400 VALIDATION_FAILED` envelope, `fields` under `error.details`, instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. + + Unchanged: a record body, `{}` included; a string `expectedVersion` (judged; `409` on a mismatch); and `If-Match` and the `DELETE`'s `?expectedVersion`, which are strings by transport. A JSON `null` body on a `POST` create is refused too, on both doors, in the companion entry for the `/data` create and query requests. + + To send a patch or a row, send the fields as a JSON object. To guard a write, send the version you read (the record's `updated_at`) as a string. The `@objectstack/client` SDK's `data.update` sends the version as an `If-Match` header string, so an SDK caller is affected only when the `data` it passes is not an object, or carries a number `expectedVersion`. +- 1f3ac4f: fix(rest,runtime)!: a `/data` `PATCH` or `DELETE` whose version token can never carry a version (`expectedVersion: ''` or `false`, `?expectedVersion=`, an empty `If-Match`) is refused `400 VALIDATION_FAILED` on both doors, instead of being applied unguarded beside a stale `If-Match` + + Both doors of `PATCH /data/:object/:id` and `DELETE /data/:object/:id`, `RestServer` and the runtime dispatcher's `/data` domain, read an optimistic-concurrency token from the body's `expectedVersion` (`PATCH`) or `?expectedVersion` (`DELETE`), else from `If-Match`. They forwarded it to the protocol only when it was truthy. `''` and `false` are not nullish, so a stale `If-Match` beside them was never read, and they are not truthy, so no token was forwarded: the write ran unguarded and answered `200`. The protocol already refuses the quoted-empty tag `""` with `400 VALIDATION_FAILED`, under the ruling that a token shape that can never carry a version is refused, neither read as "no token" nor answered `409`. It never saw these tokens, because the door dropped them first, and it reads a blank string as no token. Both doors now judge every token a caller sends, or refuse it before the protocol is asked, and they answer each token alike. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, on both doors, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + + - FROM a `PATCH` whose body `expectedVersion` is `''`, a blank string, `false` or an empty array, or a `DELETE` whose `?expectedVersion` is empty or blank, which wrote or deleted the record unguarded and answered `200` even beside a stale `If-Match`, TO `400 VALIDATION_FAILED` with the record unchanged, before the protocol is asked. + - FROM an empty `If-Match` sent with no body or query token, which wrote or deleted unguarded and answered `200`, TO the same `400 VALIDATION_FAILED`. An `If-Match` that is sent asks for a guard, and an empty one carries nothing to judge. A request that sends no `If-Match` at all is unchanged. + - FROM a body `expectedVersion` of `true`, an object or a non-empty array, which was judged as its string (`'true'`, `'[object Object]'`, the comma-joined elements), TO the same `400 VALIDATION_FAILED`. + - FROM a body `expectedVersion` of `0`, which was dropped like `''` and so wrote unguarded, TO a token judged as `'0'`, as every other number already was. On a real record that is `409 CONCURRENT_UPDATE`. + + How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields: [], object }`, the envelope it already gives the protocol's `""` refusal. The dispatcher returns its own `400 VALIDATION_FAILED` envelope instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. That is unlike the refusals the protocol throws out of `dispatch()`, `409 CONCURRENT_UPDATE` and the `400` for `""`, which that host still renders as `500` (the record is unchanged), because its catch-all reads only an error's `statusCode`. Rendering their declared status is a separate fix. + + Unchanged: a request with no token, a body `expectedVersion: null` (it is no token, so `If-Match` is read), a non-empty string or a non-zero number token (judged; `409` on a mismatch), and `""` (the protocol's own `400`). The body key is still stripped from the patch whatever its value. + + To write over a version you read, send that version (the record's `updated_at`) as the token. To write unguarded, omit both `expectedVersion` and `If-Match` rather than sending either one empty. The `@objectstack/client` SDK's `data.update` and `data.delete` already omit `If-Match` when `ifMatch` is empty, so an SDK caller is affected only when its `data` carries an `expectedVersion` that can never carry a version. +- c263588: fix(rest,runtime)!: the authoring-trail doors (`/meta` history, audit and diff, and package commits) stop admitting `setup.access`, so an organization admin is refused the environment's authoring trail + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Organization admins hold `setup.access`. Under a wall (the `isolated` and `group` postures), these four doors handed every organization's admin the environment-wide authoring trail of items no organization authors (ADR-0131 C5): who saved each item and when, its superseded bodies, and every package's commit timeline. The data door on the same three tables (`sys_metadata_history`, `sys_metadata_audit`, `sys_metadata_commit`) refuses them (`requiredPermissions: ['studio.access']`, ADR-0131 D7), and the doors now follow the same rule. The narrowing applies in every posture, `single` included. + + Each door, and the callers it now refuses: + + - **BREAKING** `GET /meta/:type/:name/history` (`@objectstack/rest`), for a caller holding `setup.access` and neither `studio.access` nor `manage_metadata`: was 200 with the item's version history; now `403 FORBIDDEN`. + - **BREAKING** `GET /meta/:type/:name/audit` (`@objectstack/rest`), for the same caller: was 200 with the item's audit rows; now `403 FORBIDDEN`. + - **BREAKING** `GET /meta/:type/:name/diff` (`@objectstack/rest`), for the same caller: was 200 with a diff of the item's stored versions; now `403 FORBIDDEN`. + - **BREAKING** `GET /packages/:id/commits` (`@objectstack/runtime`), for a caller holding `setup.access` without `studio.access`, **with or without `manage_metadata`**: was 200 with the package's commit timeline; now `403 PERMISSION_DENIED`. + - `manage_metadata` never admitted this door. A caller holding it alone was refused before and still is; a caller holding it beside `setup.access` was admitted only through `setup.access`. + - One such caller is a walled organization owner granted `manage_metadata` inside its own organization. It keeps history, audit and diff, and loses commits. + + Who is still admitted: + + - History, audit and diff admit a system caller, `studio.access` or `manage_metadata`, through their own predicate. + - The commits door admits a system caller or `studio.access`. + - Both capabilities are platform-scoped. Platform operators are not affected, and neither are metadata authors who hold `studio.access` (all four doors) or `manage_metadata` (history, audit and diff; commits never admitted it). + + Unchanged: + + - The draft switches keep their admission, `setup.access` included: `GET /meta/_drafts`, `?state=draft` and `?preview=draft`. + - `GET /packages`, `GET /packages/:id` and `GET /packages/:id/export` keep their read cohort (`studio.access` or `setup.access`). + - The refusal keeps its envelope and code at each door. Only the message changes: it now names the capabilities that admit the door. + + What changes for you. If an organization admin needs a metadata item's history, audit trail or package commits, grant `studio.access`. A `manage_metadata` holder without `studio.access` who read package commits through `setup.access` needs `studio.access` for them too. A client that offers History or Audit to a caller holding only `setup.access` should hide them for that caller. The Setup app's item editors do this in objectstack-ai/objectui#12475. +- 7c6b7f4: fix(rest,runtime)!: a JSON `null` body on a `/data` create or query is refused `400 VALIDATION_FAILED` on both doors; the runtime dispatcher's `POST /data/:object/query` validates `FindDataRequestSchema`; and the dispatcher asks an object's API exposure before any request gate + + `RestServer` read a JSON `null` body on `POST /data/:object` as an empty record and created an empty row, and on `POST /data/:object/query` as no query and answered the full record set. The runtime dispatcher's `/data` domain, the only answer on a host that mounts just the `@objectstack/hono` catch-all, did the same for the create, and validated no query at all: a mistyped clause or a retired key reached the engine, which dropped it and answered the full record set. `CreateDataRequestSchema.data` declares a record and `FindDataRequestSchema.query` declares a QueryAST or nothing, never `null`. Both doors now validate the assembled request as the caller sent it, forward that same request, and answer each case alike. On an object whose API exposure denies the verb, the dispatcher used to answer a `PATCH` body that is not a record, or a create body that is an array, a string or a number, with its request gate's `400`, where `RestServer` answered the exposure refusal; it now asks the exposure gate before each request gate, as `RestServer` does. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you, wherever `@objectstack/rest` or the runtime dispatcher serves these routes: + + - Both doors, `POST /data/:object` with a JSON `null` body: FROM `201` and an empty row created, TO `400 VALIDATION_FAILED` naming `data`, with no row created. + - Both doors, `POST /data/:object/query` with a JSON `null` body: FROM `200` with the full record set, TO `400 VALIDATION_FAILED` naming `query`, the engine never asked. + - The runtime dispatcher, `POST /data/:object/query` with a mistyped clause (`{ "limit": "ten" }`), the retired `joins` key, or a number as the body: FROM `200` with the full record set, the clause dropped, TO `400 VALIDATION_FAILED` naming the member (`query.limit`, `query.joins`, `query`). `RestServer` already answered this. + - The runtime dispatcher, `POST /data/:object/query` with a string or an array as the body: FROM the engine's `400 INVALID_FIELD` for numbered field names, TO `400 VALIDATION_FAILED` naming `query`. `RestServer` already answered this. + - The runtime dispatcher, `POST /data/:object/query` with a body that nests the query under a `query` key (`{ "query": { "where": … } }`): FROM read as the nested query (`200`), TO read as `RestServer` reads it, the body itself being the query, so `query` is a field the object does not have: `400 INVALID_FIELD`. + - The runtime dispatcher, `PATCH /data/:object/:id` with a body that is not a record (an array, a string, a number or `null`), and `POST /data/:object` with an array, a string or a number as the body, on an object whose API exposure denies the verb: FROM `400 VALIDATION_FAILED`, TO the exposure refusal `RestServer` answers (`404` when the object sets `apiEnabled: false`, `405` when its `apiMethods` leaves the verb out). The request was refused before and is refused now; only the answer changes. A JSON `null` create on such an object already answered the exposure refusal (the old gate read it as `{}`), and so did every query on it: the query route had no request gate, and its new one runs after the exposure gate, as `RestServer` orders it. + + How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields, object }`, the envelope its request gates already use. The runtime dispatcher returns its own `400 VALIDATION_FAILED` envelope, `fields` under `error.details`, instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`. + + Unchanged: an empty request body, which every host hands over as `{}` (an empty record creates a row; an empty query answers every record); a record body; a conforming query; and a body `object` key on a query, which the path object still overrides. + + To create a row, send its fields as a JSON object, `{}` for all defaults. To query, send the QueryAST itself as the body, `{}` for every record. The `@objectstack/client` SDK's `data.create` and `data.query` send exactly these, so an SDK caller is affected only when the value it passes is `null`, or not the shape the request declares. + +### Patch Changes + +- c980d11: fix(plugin-sharing, runtime): a shared conversation's messages open for exactly the callers its share link opens for, on both mounts + + Clause-②: no — `/messages` now reaches the answer `/resolve` already gives the same caller for the same link, on the same mount; who can open a share link is unchanged. + + A share link to an AI conversation has two public routes: `/resolve` returns the conversation, and `/messages` returns its messages. Both check the link through the same service call. `/resolve` passed it every input an audience is judged by. `/messages` passed fewer, so it refused callers `/resolve` served: + + - **A `signed_in` link, a signed-in caller:** plugin-sharing's share-link routes answered `/messages` with `404 NOT_FOUND`, so the console's shared-conversation page showed the conversation with no messages. It now returns the messages, as the runtime dispatcher's share-links domain already did. + - **An `email` link, an allowlisted `?email=`:** both mounts answered `/messages` with `404 NOT_FOUND`. Both now read `?email=` on `/messages` exactly as on `/resolve`, and return the messages. + - **Unchanged:** every caller `/resolve` refuses is still refused on `/messages` with `404 NOT_FOUND`: an anonymous caller of a `signed_in` link, and a caller of an `email` link with no email or an off-list email, signed in or not. A password-protected link still needs its password on both routes. `/resolve` is unchanged. +- 0ecac96: fix(plugin-sharing, runtime): a share link limited to an email allowlist answers every caller it does not open for with `404 INVALID_OR_EXPIRED`, whatever email and password were sent + + Clause-②: no — every request refused before is still refused, and every request served before is still served; only which refusal code a caller reads changes. + + A share link can carry a password and the `email` audience together. For such a link, the share-link resolve door answered a caller it did not open for from the password, never the audience: `401 NEEDS_PASSWORD` with no password, and `401 WRONG_PASSWORD` with any password, the correct one included, when the email sent was missing or not on the allowlist. A caller with the right password read "Incorrect password". + + - **An `email` link, any caller it does not open for:** `404 INVALID_OR_EXPIRED`, with or without an email, with no password, a wrong one or the correct one, signed in or not. It is the answer a password-less `email` link already gave and an unknown token gives, so it tells the caller neither whether the email is on the allowlist, nor whether the password is right, nor whether the link has a password. The door does not read the email to choose its answer. + - **Served, unchanged:** an allowlisted email with the correct password (or, on a link without a password, an allowlisted email alone) still opens the record. An API or SDK caller sends both together; the door no longer prompts for the password first. + - **Unchanged:** links with any other audience, including the `signed_in` order (`401 SIGN_IN_REQUIRED` before sign-in, the password arms after), a `public` link's `NEEDS_PASSWORD` / `WRONG_PASSWORD`, and the expired-or-revoked `410 EXPIRED_OR_REVOKED`. + - Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain. +- 70d4c06: fix(runtime): a flow name two packages hold is refused at `/api/v1/automation` with a remedy that works from there: the metadata door's `?package=` route, not the `?package=` this door does not read + + Clause-②: no + + A flow name that two or more packages hold is refused `409 RESOURCE_CONFLICT` by the `/automation` definition doors, because they save and delete without naming a package. The refusal is unchanged. Its remedy is not: the doors used to relay the metadata store's sentence, "on the REST door add ?package= with the package id", and `/automation` reads no `?package=`. A caller who followed it got the same `409` again. + + The answer is still `409 RESOURCE_CONFLICT`. Its text now names a route that takes the package, and still states what was refused and which packages hold the name: + + - **`PUT /api/v1/automation/NAME` and `POST /api/v1/automation`** name `PUT /api/v1/meta/flow/NAME?package=` with one of the listed packages and the flow definition as the body. + - **`DELETE /api/v1/automation/NAME`** names `DELETE /api/v1/meta/flow/NAME?package=`, which removes that package's row and keeps the others. That route is the REST server's `/meta` door, the one `os serve` mounts. A host that sends every request to the runtime dispatcher (the `@objectstack/hono` catch-all) serves no `DELETE` on `/meta` and answers it `405`. + - **`POST /api/v1/automation/SOURCE/clone`** onto such a name answers the clone door's own taken-name refusal: a clone takes a new machine name, and the refusal suggests one. + + The door recognises this refusal by its envelope (`RESOURCE_CONFLICT` / 409), then reads the packages from the served flow list. When that list does not show the name held by two or more packages, the store's refusal is relayed as before. +- 4feb2d0: fix(metadata-protocol): every write over a sealed, package-held name reads one sentence that states what the door knows; the inferred create remedy retires + + Clause-②: no + + + + **BREAKING** (a public method-signature narrowing in `@objectstack/metadata-protocol`), shipped as `minor` under the launch-window convention for breaking changes. The `'create'` member of `ObjectStackProtocolImplementation.packagedBaseRefusal`'s `operation` (now `'save' | 'delete'`) and the optional `operation` parameter of `tenantAuthoredWriteRefusal` retire. A caller that passed `operation: 'create'` passes `'save'` (or omits `operation` on `tenantAuthoredWriteRefusal`): the verdict, the code and the status were always the same. + + **What was wrong.** A save over a name a managed package holds, on a type with no environment overlay, is refused `403 NOT_OVERRIDABLE`. The sentence guessed what the save meant from `If-None-Match: *`. A publish-mode `*` was told "… so its name is taken. To author your own TYPE, choose a name no package or built-in holds …", and every other save, the console's generic create (`?mode=draft` with `If-None-Match: *`) included, was told "… Edit the source artifact and redeploy." Neither request declares a create or an edit, so each remedy was wrong for one of its callers. An editor who followed the first authored a second item. A creator who followed the second, and every administrator of an installed package, was sent to an artifact they cannot edit. + + **What changed.** + + - `@objectstack/metadata-protocol`: every write over such a name, on a type with no regime row, reads one sentence, whatever its mode or pin: "'TYPE/NAME' is provided by a managed package and is sealed: it cannot be written here. GET /api/v1/meta/TYPE lists the names in use." The same sentence comes from the repository's type door (draft promotion, restore, revert). It gives no create advice and no source-artifact advice. It names no holding package, because neither door has the package at hand. A type with a regime row (`flow`, `action`, `permission`, `datasource`) keeps its regime sentence, and every removal keeps its sentence. + - `@objectstack/metadata-protocol` (retired, no producer remains): the two signature members named under **BREAKING** above, and the `create` arm of the internal `SealedItemOperation`. + - `@objectstack/spec`: the `GetMetaItemResponseSchema.version` and `MetadataConflictErrorSchema.currentVersion` descriptions say that a `null` beside `editable: false` takes no save, and prescribe no `If-None-Match: *` pin for it. + - `@objectstack/runtime`: a permission-set clone (`POST /api/v1/security/permission-sets/:name/clone`) whose save loses the race for the target name now answers the door's own `409` name-taken refusal ("Permission set 'NAME' already exists — a clone must take a NEW machine name. …", code `RESOURCE_CONFLICT`, where it used to answer code `METADATA_CONFLICT`), the same refusal a name the catalog already held reads. It used to relay the `/meta` door's conflict sentence, which told the caller to send `If-Match`, and this door takes no `If-Match`. + + **Unchanged.** Which requests are accepted or refused. The `NOT_OVERRIDABLE` code and `403` status. The named read-only base's `ITEM_LOCKED` sentence. The `/meta` door's conflict sentence for its own callers. +- cd86e41: fix(objectql,runtime): a stack's `datasourceMapping` is installed before the engine's first schema sync, and the datasources it routes platform objects to are connected then, so a served boot keeps every object in the store its rule names (#23649) + + Clause-②: yes + + A mapping rule is routing, not a hint (ADR-0062, the `datasourceMapping` half of D2): every object it matches resolves to the datasource it names. `AppPlugin.start()` installed the rules late. By then `ObjectQLPlugin.start()` had schema-synced every registered object onto the host's default store, and the auth plugin had written the Default Organization there. A host config declaring `datasourceMapping: [{ default: true, datasource: 'primary' }]` over a declared `primary` reached `Server is ready` with `OS_DATABASE_URL` set (or under `--dev`), and every platform read then failed with `no such table`, because the platform tables and the organization were in the URL store and `primary` had neither. `os start` with `OS_DATABASE_URL` set to a different database than the rule names behaved the same way. A rule that routes platform objects only (`objectPattern: 'sys_*'`) also left its datasource unconnected, because the connect gate counted the app's own objects only. Every platform read then failed with "mapped for object … is not registered". + + **What changes** + + The public surface widens by one export and one event: `@objectstack/objectql` now exports `OBJECTQL_DRIVERS_READY_HOOK`, and `ObjectQLPlugin.start()` triggers the kernel event it names, `objectql:drivers-ready`, which any plugin can subscribe to. + + - **`@objectstack/objectql`.** `ObjectQLPlugin.start()` triggers the kernel event `objectql:drivers-ready` (exported as `OBJECTQL_DRIVERS_READY_HOOK`), with the engine as its argument. It fires once per boot, after the boot drivers are adopted and connected and before the first schema sync, the `sys_metadata` hydration or any read or write. A handler that throws fails the boot. + - **`@objectstack/runtime`.** `AppPlugin` handles that event. It installs the stack's `datasourceMapping`, then connects every declared datasource the rules route an already-registered object to, platform objects included. The connect is the same `connectDeclared` `start()` uses, with the same fail-fast verdict. The first DDL, the hydration and the Default Organization then land on the mapped datasource. On a composition whose engine never fires the event, `start()` installs the rules as before. + + **Unchanged** + + - A config with no `datasourceMapping`, and a rule naming the host's `default`. + - The engine's default driver. The mapped datasource connects after the engine has adopted its `driver.*` services, so it never takes the default slot from a stack plugin's driver. + - Where an object's rows live after boot. The rules were already in force once `start()` had run. + - `os start` on the config-declared default (`datasourceMapping` `{ default: true }` and no URL). The resolver points the host default at the same database, so everything was already in one file, and still is. + - A rule routing only the app's own objects. Their rows were already written to the mapped datasource. The only difference is that the first sync no longer creates an empty copy of their tables in the default store. + + **Refused at boot, as documented.** The docs already say a rule that routes objects to a datasource that cannot be connected fails the boot. A rule that routed only platform objects to such a datasource used to reach `Server is ready` with every platform read failing. It now fails the boot with the connect error and the objects routed to the datasource. The remedy is to make the datasource reachable, or to remove the rule. + + **Still on the default store:** the package registry's own `sys_packages` table. It is not an object, so no rule routes it, and the package service creates and reads it on the engine's default driver. +- 395ca21: fix(runtime): the HTTP dispatcher answers the engine's `DuplicateRecordError` with the wire code `UNIQUE_VIOLATION`, not the engine's in-process `DUPLICATE_RECORD`, with `developerMessage`, `object` and `field` in `error.details` (#23686) + + **Wire change at the dispatcher's doors: `409 DUPLICATE_RECORD` becomes `409 UNIQUE_VIOLATION`.** It applies wherever a dispatcher domain hands a caught engine throw to its shared error writer (`HttpDispatcher`'s thrown-error envelope). Measured: `POST /actions/:object/:action`, for a handler or a declarative `operation: 'update'` action whose write repeats a unique value, and the activation doors `POST /actions/_activation/:object/:action` and `POST /security/_activation/:type/:name`, for a flip that loses a race on the activation ledger's unique key. A client of these doors (`@objectstack/hono`'s `createHonoApp`, or any host serving the dispatcher) that matched `code === 'DUPLICATE_RECORD'` on that refusal must match `code === 'UNIQUE_VIOLATION'` instead. That is what the REST server and both Hono doors already answer for the same refusal. + + Why: the maintainer ruled one wire spelling for a unique-constraint refusal on every route, `UNIQUE_VIOLATION` (recorded in commit 65846bc46). The REST server, the batch and import rows and the Hono doors already reported it. The dispatcher's own writer reported the engine's spelling. + + - FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …","httpStatus":409}}` + - TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","httpStatus":409,"details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}` + + `details` carries what the REST server's answer carries beside its code, each field only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The status stays `409` and the `message` is unchanged. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A handler or hook that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. Other `409` answers at these doors (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`) are unchanged. + + Clause-②: no +- 9e0945c: fix(runtime): a sandboxed body that lets the engine's `DuplicateRecordError` leave unchanged is answered `409 UNIQUE_VIOLATION`, not `409 DUPLICATE_RECORD`; a body that throws `DUPLICATE_RECORD` itself keeps it (#23702) + + **Wire change at the action door: `409 DUPLICATE_RECORD` becomes `409 UNIQUE_VIOLATION`.** It applies to `POST /api/v1/actions/:object/:action` on a `type: 'script'` action whose sandboxed body writes through `ctx.api` with a value a unique key already holds and does not catch the refusal, or catches it and re-throws it unchanged. The same holds when the refusal comes from a sandboxed hook body's own write, fired by the action's write. A client that matched `code === 'DUPLICATE_RECORD'` on that refusal must match `code === 'UNIQUE_VIOLATION'` instead, which is what the dispatcher already answers when a host-code handler's write is refused. + + Why: the maintainer ruled one wire spelling for a unique-constraint refusal on every route, `UNIQUE_VIOLATION` (recorded in commit 65846bc46). Every door translates the engine's class, recognised by its registered code and its class name together. The QuickJS runner rebuilt the escaping refusal as a `SandboxError` carrying the code without the class, so no door recognised it. + + - FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"DuplicateRecordError: Duplicate record refused on 'udoc': …","httpStatus":409}}` + - TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","httpStatus":409,"details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}` + + The fix is in the runner, so no door changed: when the error leaving the body is the very object the engine's refusal became inside the VM, with nothing that crosses the VM rewritten on it, the runner throws the engine's own `DuplicateRecordError`. `message` loses the `DuplicateRecordError: ` class-name prefix, and `details.object` names the object the engine refused. + + Control, unchanged: a body that throws its own error carrying `DUPLICATE_RECORD` keeps `409 DUPLICATE_RECORD` and its own sentence, whatever `name` it gives that error. A body that rewrote the engine's error (its `name`, `message`, `code`, `status`, `fields` or `userMessage`) before throwing it is speaking for itself and is answered exactly as before. Every other host error leaves the runner as a `SandboxError`, exactly as before. + + In-process: a caller of a write whose sandboxed hook body lets the refusal leave, or of a script action whose body does, now catches the engine's `DuplicateRecordError` (`code` `DUPLICATE_RECORD`, `status` `409`, `object` the refused object) rather than a `SandboxError`. `code` and `status` are unchanged, and the error no longer carries `innerMessage` or the `hook 'NAME' threw:` wrapper in `message`. + + Clause-②: no +- 6ee483c: fix(runtime): a sandboxed body that throws a plain `{ code, message }` object is answered with its own `message` as the client sentence, never `undefined: MESSAGE` or `[object Object]`; its code is kept (#23718) + + **Client sentence change, status and code unchanged.** It applies to `POST /api/v1/actions/:object/:action` on a `type: 'script'` action whose sandboxed body throws a value that is not an `Error`, and to a sandboxed hook body that does the same (its refusal at the action door behind a write, and in the `/data` error classification). The QuickJS runner flattened every thrown value as `NAME: MESSAGE`, else `String(value)`. A plain object has no class name and no `toString` of its own, so the doors served `undefined: MESSAGE`, or `[object Object]` when it had no `message`. + + - `throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' }` + - FROM `{"success":false,"error":{"code":"VALIDATION_ERROR","message":"undefined: That address is already on file","httpStatus":400,"declaredCode":"EMAIL_TAKEN"}}` + - TO `{"success":false,"error":{"code":"VALIDATION_ERROR","message":"That address is already on file","httpStatus":400,"declaredCode":"EMAIL_TAKEN"}}` + - `throw { code: 'DUPLICATE_RECORD' }` + - FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"[object Object]","httpStatus":400}}` + - TO `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Internal server error","httpStatus":400}}` + + A value with no usable `message` and no `toString` of its own is answered with `Internal server error`, the sentence every door already answers for a fault with no business message. To give the caller words, throw a `message` (or an `Error`). + + Two outcomes move with it, both for a thrown plain object only: + + - A `name` on a plain object is a field, not a class: `throw { name: 'TypeError', message: 'forged' }` was read as a crash (`500 INTERNAL_ERROR`); it is now a refusal, `400` with `forged`. + - A plain object whose `String()` is empty (`throw []`) recorded no rejection, so the request waited for the sandbox's wall-clock ceiling and answered `500`; it now answers at once with the no-message sentence. + + Control, unchanged: a thrown `Error` (or subclass) keeps `NAME: MESSAGE` in the server log and the client sentence it got before. A thrown string is its own sentence, and an object with a `toString` of its own keeps it. + + In-process: the `SandboxError` a caller catches for such a throw carries the value's `message` as `innerMessage`, and its log-only `message` reads `KIND 'NAME' threw: Error: MESSAGE`. + + Clause-②: no +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [086fa46] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [249130f] +- Updated dependencies [b1e9280] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [73c7dc4] +- Updated dependencies [3b28905] +- Updated dependencies [1baf976] +- Updated dependencies [222293f] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [7cb33cf] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [989aa48] +- Updated dependencies [56147a8] +- Updated dependencies [23e689d] +- Updated dependencies [a869ea6] +- Updated dependencies [7b64a3d] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [c2bb302] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [e611e2b] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [c7dd48a] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [f77ba41] +- Updated dependencies [76560a1] +- Updated dependencies [9bb9370] +- Updated dependencies [53646fc] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/plugin-security@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-protocol@18.0.0-next.2 + - @objectstack/rest@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + - @objectstack/service-datasource@18.0.0-next.2 + - @objectstack/service-i18n@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/driver-sql@18.0.0-next.2 + - @objectstack/driver-turso@18.0.0-next.2 + - @objectstack/driver-sqlite-wasm@18.0.0-next.2 + - @objectstack/driver-memory@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + - @objectstack/service-cluster@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d99667c6fea..7bc9cfb82ac 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack Core Runtime & Query Engine", "type": "module", diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md index e10607b2e78..2065a3a2c8f 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,9 @@ # @objectstack/sdui-parser +## 18.0.0-next.2 + +No changes in this release. + ## 18.0.0-next.1 No changes in this release. diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 06a576e15b4..41e03442bcf 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", "main": "dist/index.js", diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index 3835bce2df3..e15f8727fed 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog — @objectstack/service-analytics +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 6187be9c3f3..5601b57bdfa 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)", "type": "module", diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md index 4c44540e86d..ca1bec19d7f 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,144 @@ # @objectstack/service-automation +## 18.0.0-next.2 + +### Minor Changes + +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. + +### Patch Changes + +- d176b0a: feat(spec)!: the loose `config` keys of a `wait` or `connector_action` flow node are refused, naming the `waitEventConfig` / `connectorConfig` key they belong on, and `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` leave the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** neither node type has a `config` contract: the `wait` executor reads only `waitEventConfig` and the `connector_action` executor only `connectorConfig`. Through 17 the loader lifted settings written under the node's `config` into that block with a notice. At 18 both ADR-0087 D2 conversions are retired from the load path (`retiredAfter: '17.7.0'`), and the flow parse refuses each such key where it is written, at every door that judges a flow (`defineStack`, `os validate`, `os build`, and `os lint` when the config calls it), a node inside a loop / parallel / try-catch region body included: "A `wait` node's `config.duration` was removed in @objectstack/spec 18 (ADR-0087 D2) … Move it to `waitEventConfig.timerDuration` …". The refused keys are exactly the ones the lifts moved: `eventType`, `timerDuration`, `duration`, `signalName`, `signal` and `timeoutMs` on `wait`; `connectorId`, `actionId` and `input` on `connector_action`. Any other `config` key on these node types is judged as before. + + Migration: + + - `wait` node `config.eventType` / `config.timerDuration` / `config.signalName` → the same key under `waitEventConfig`. + - `wait` node `config.duration` → `waitEventConfig.timerDuration`, and `config.signal` → `waitEventConfig.signalName`. + - `wait` node `config.timeoutMs` → `waitEventConfig.timerDuration`, as a string (`60000` → `'60000'`, or `'PT1M'`): it was only ever read as the timer duration. + - `connector_action` node `config.connectorId` / `config.actionId` / `config.input` → the same key under `connectorConfig`. + - If the block already sets the same key, the block's value is the one the node always ran with: delete the loose key. + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. The D3 entry `flow-sibling-block-loose-config-keys-refused` states the edit for upgraders. + + Stored metadata needs no edit. The automation engine's flow rehydration (`registerFlow`), stored-row rehydration and the metadata save door's flow verdict still replay both conversions, and each now DELETES a loose key its block already shadows (and a loose key holding `null`) instead of leaving it in place, so a `sys_metadata` flow carrying either shape keeps loading with the block alone. A `connector_action` node whose `connectorId` / `actionId` pair is complete in neither place is refused as before: no conversion can complete it. + + `@objectstack/service-automation`: comment-only changes in the `wait` and `connector_action` executors, which already read only their declared blocks. + + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [3b28905] +- Updated dependencies [1baf976] +- Updated dependencies [222293f] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [7cb33cf] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [56147a8] +- Updated dependencies [23e689d] +- Updated dependencies [a869ea6] +- Updated dependencies [7b64a3d] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [c03077e] +- Updated dependencies [9bb9370] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-protocol@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/formula@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Major Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 4c6647d6126..2b458015dd0 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine", "type": "module", diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md index ec44cc5e306..7c6f28ccf84 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,54 @@ # @objectstack/service-cache +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 846a485011d..ef997773995 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters", "type": "module", diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md index 6420e98af34..08a9c20d400 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/service-cluster-redis +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/service-cluster@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 3d3605be9c9..e89454b7483 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": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.", "type": "module", diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md index b4f30085aa9..0bdf9ec3f0a 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/service-cluster +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index dfc1d42e63e..dbc94aad8ee 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.", "type": "module", diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md index 6586f019a99..f0e6fda1eaf 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/service-external-datasource +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [c7dd48a] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [f77ba41] +- Updated dependencies [53646fc] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/driver-sql@18.0.0-next.2 + - @objectstack/driver-turso@18.0.0-next.2 + - @objectstack/driver-mongodb@18.0.0-next.2 + - @objectstack/driver-sqlite-wasm@18.0.0-next.2 + - @objectstack/driver-memory@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index e60f72027a2..b3cba7995e3 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.", "type": "module", diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md index d026ba63ae6..f4bc05bdc81 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,70 @@ # @objectstack/service-i18n +## 18.0.0-next.2 + +### Minor Changes + +- 249130f: fix(service-i18n)!: `I18nServicePlugin` no longer mounts its own copy of `/api/v1/i18n/*`; the runtime dispatcher is the one owner, and the `registerRoutes` / `basePath` options are removed + + Clause-②: no + + + + **BREAKING** for code that constructs `I18nServicePlugin` with route options, and for a composition that serves HTTP without the runtime dispatcher. It ships as `minor` under the launch-window convention for breaking changes. + + **What changed.** The three i18n routes (`GET /api/v1/i18n/locales`, `/translations/:locale`, `/labels/:object/:locale`) had two registrations: the runtime dispatcher's, in its `start()`, and this plugin's, in a `kernel:ready` hook. Measured on `objectstack serve` over the showcase app, the dispatcher's registration answered every request on all three paths, because it registers first and the HTTP adapter answers a duplicate path with its first registration. The plugin's copy was never reached there. Where it could be reached — a composition with an HTTP server and no dispatcher — it did not apply the authenticated-caller floor the dispatcher's i18n domain applies (ADR-0056 D2). It is removed: one route, one owner. The plugin still registers the `i18n` service and still syncs runtime-authored translations; the dispatcher's `/i18n` domain serves the routes from that service, exactly as before on `objectstack serve` and `objectstack dev`. + + **If you passed `registerRoutes` or `basePath`.** Delete them from the options object. `registerRoutes: false` is now what always happens. There is no replacement for `basePath`: the routes live where the dispatcher mounts them. + + **If you serve HTTP without the dispatcher.** The plugin no longer answers `/api/v1/i18n/*` there. Compose the runtime dispatcher (`createDispatcherPlugin` from `@objectstack/runtime`) to serve the i18n routes, with its authenticated-caller floor. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 8cc523607be..74e43488f07 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", "type": "module", diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md index 5478f6db3cd..f047560414d 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-job +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 3fa9bb3293f..68afc70053f 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling", "type": "module", diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md index 6b3265b6bcd..efcdb103bb4 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/service-knowledge +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index ad10ca09f56..32d90df611d 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-mailbox/CHANGELOG.md b/packages/services/service-mailbox/CHANGELOG.md index af2f34d69e3..9f00a75e584 100644 --- a/packages/services/service-mailbox/CHANGELOG.md +++ b/packages/services/service-mailbox/CHANGELOG.md @@ -1,5 +1,75 @@ # @objectstack/service-mailbox +## 18.0.0-next.2 + +### Minor Changes + +- b60eb78: Inbound mail reads a forward quoted inline in the body: a message an organization user forwards inline from Gmail, Outlook or Apple Mail now associates with the records its original participants match + + Clause-②: no + + - **What is read.** The "Forwarded message" block each of the three clients writes into the text/plain body, with its English labels: Gmail's `---------- Forwarded message ---------` block (From, Date, Subject, To, Cc), the block Outlook on the web and Microsoft 365 write under a line of 32 underscores (From, Sent, To, Cc, Subject), and Apple Mail's `Begin forwarded message:` block (From, Subject, Date, To, Cc, Reply-To). Its From, Reply-To, To and Cc addresses join the participant set in `match_addresses` under the source `inline`, after the outer and attachment entries. `rematch` replays them with the rest of the stored set and never reads the body again. + - **Only an organization user's forward is read.** The block is read only when the message's From names one address and that address is the email of a user (`sys_user`, ASCII case-insensitive) who is a member (`sys_member`) of the mailbox's organization. A forward from anyone else, a member of another organization included, is matched on its own headers only. Where the identity objects are not registered (no auth plugin is composed), no inline forward is read, and the first message that carries one says so in the log. + - **An unrecognised block pools.** A block the parser does not recognise is not guessed at: labels of another client or locale (Gmail in German writes `Von:`), a header line wrapped onto two lines, a block with no date line, a forward quoted by a reply, or a message with an HTML body only. The message is matched on its other participants, so a forward whose only match was in the block waits in the pool. Only the first block in the body is read; a forward quoted inside that forward is not. +- 05e3b8e: Inbound mail stores its attachments: each attachment of an ingested message, and each message it forwards as an attachment as an `.eml` file, is stored through the `file-attachments` service as a file of the message's `sys_mail_message` row + + Clause-②: no + + - **What is stored, and where.** Every part of a stored message besides its text and HTML bodies (inline parts included, and each forwarded `message/rfc822` or `message/global` part as the `.eml` file it travels as) becomes one `sys_file` and one `sys_attachment` whose parent is the message's own `sys_mail_message` row, written by `IFileAttachmentService.attachFile` in the mailbox's organization after the row, and only for a message the call stored, so a re-delivered copy adds no attachment. + - **Without the storage capability.** A kernel that composes no `file-attachments` service (registered by `@objectstack/service-storage`) stores each message without its attachments, and says so once in the log. + - **Refused attachments.** A part the service refuses, such as one over the `max_upload_mb` upload size limit, is logged at `error` with its file name and the refusal, and the message and its other attachments are still stored. + - **For a host that constructs `MailIngestService` itself.** It takes the service as the optional `attachments` resolver (`MailboxServicePlugin` supplies it), and its `MailIngestLogger` now needs `error`, as `MailSyncLogger` already does. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + - @objectstack/connector-imap@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/services/service-mailbox/package.json b/packages/services/service-mailbox/package.json index 23223b2634b..b4ca59e9b3c 100644 --- a/packages/services/service-mailbox/package.json +++ b/packages/services/service-mailbox/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-mailbox", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Mailbox Service for ObjectStack \u2014 inbound mail: the mirrored message (sys_mail_message), its record associations and their read gates.", "type": "module", diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 7e589bbfe31..ea05f0d35dc 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/service-messaging +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index fe6109c9387..8773168a82a 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index 4a73a60fab5..114cd1faa14 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-package +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index eb3f345d1ed..2f76a90d018 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index f14d267e9fb..8a98324ead2 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-queue +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index f867afc5047..69de4c14324 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters", "type": "module", diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md index e84656af761..3e989e5d2df 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,55 @@ # @objectstack/service-realtime +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index acc4738f498..da37dc7e4e7 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", "type": "module", diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md index ebe9f047bfa..3afaebe9489 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,109 @@ # @objectstack/service-settings +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index c25b5c7c365..c28e559c77b 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.", "type": "module", diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md index 085603e1f7c..cce0716fd12 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,57 @@ # @objectstack/service-sms +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [e611e2b] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 36280e584d8..f70d537d07a 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", "main": "dist/index.js", diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md index dd0cd2b0068..2c69b36ec1f 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,131 @@ # @objectstack/service-storage +## 18.0.0-next.2 + +### Minor Changes + +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. + +### Patch Changes + +- daafae6: fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call + + Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal. + + + + **BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not. + + - **The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there. + - **The MCP stdio transport (`@objectstack/mcp`).** `OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code` `MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart. + - **The owner-posture input is required (`@objectstack/core`).** `ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change. + - **`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`. + - **The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire. + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both. + - **Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered). +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/observability@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 54781b17ad0..5a6fe2edd04 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/skills/CHANGELOG.md b/packages/skills/CHANGELOG.md index ac61bf65c5d..c21ae8ef49b 100644 --- a/packages/skills/CHANGELOG.md +++ b/packages/skills/CHANGELOG.md @@ -1,5 +1,20 @@ # @objectstack/skills +## 18.0.0-next.2 + +### Patch Changes + +- 11d4c5a: docs(skills): the `objectstack-data` lint-rule reference drops `security-external-wider-than-internal`, retired with `externalSharingModel` (#23268) + + Clause-②: no + + The row named a rule `@objectstack/lint` 18 no longer has, and nothing an author writes changes. +- 5477020: docs(skills): the `objectstack-data` security rule states a grant's uniqueness as the declared `unique: 'organization'` index holds it, in place of the retired `permission_set_id` column (#23615) + + Clause-②: no + + `skills/objectstack-data/rules/security.md`'s grant paragraph closed with "`permission_set_id` is a legacy column: do not write it." The grant object `sys_user_permission_set` no longer declares that column, so a write naming it is refused `400 INVALID_FIELD` as an undeclared field, and the sentence taught an AI author a key the declared shape does not have. The sentence is replaced by the rule the declared shape holds: one grant per user, set and organization, enforced by the join object's `unique: 'organization'` index on (`user_id`, `permission_set`, `organization_id`). The by-name reference — `permission_set` takes the set's `name` — and its refusals are unchanged. No behaviour changes; nothing an author writes changes. + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/skills/package.json b/packages/skills/package.json index fc32278653f..c9ea66c60ec 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/skills", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "description": "The published ObjectStack skills catalog \u2014 the repository's skills/** tree, shipped at the version of the @objectstack/* packages it teaches.", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/skills", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index c8dfa3b935f..46dfc6529f0 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,768 @@ # @objectstack/spec +## 18.0.0-next.2 + +### Minor Changes + +- 87d0619: feat(spec): the app item read answers by package id. `GET /meta/app/` returns the package's app, or the same 403 / 404 the read by name gives (ADR-0048 §3.1) + + Clause-②: yes (widening) + + ADR-0048 §3.1 makes an installed package's id the route key of its UI. With one app per package the app is the package container, so the console opens an app at `/apps/`. The app item read now accepts that key too. + + - **`GetMetaItemRequestSchema.name`** declares it. For `type: 'app'` only, a key in the package-id grammar (`MANIFEST_ID_PATTERN`, at least one dot) names the package. App names are snake_case with no dot, so the two grammars never overlap and every read by name is unchanged. The new export `isAppPackageIdKey(type, name)` is the one test for the arm. + - **`getMetaItem`** (`@objectstack/metadata-protocol`) resolves it to the package's one app, as the package-scoped list (`GET /meta/app?package=`) serves it, read by its own name in that package. The response's `name` is the app's name. Both hosts' item reads (`RestServer` and the `@objectstack/hono` catch-all) answer it with no change of their own, and the per-caller read gate judges the app exactly as it does by name: + - a caller the app is withheld from gets `403 PERMISSION_DENIED`; + - an unknown package, a package with no app, a package whose app is unpublished, and a package holding more than one app all get the `404` absence answer, byte-identical to the one for an unknown app name. A package holding more than one app (ADR-0019 D3 allows at most one) is also reported once in the server log, naming its apps; + - a caller who holds the app is served it. + - A `packageId` (`?package=`) naming a different package than the key is refused with `400 INVALID_REQUEST`. + - `/meta/app//history`, `/audit` and `/diff` stay by name (`@objectstack/rest`): the package-id key belongs to the plain item read only. + + From a client: `client.meta.getItem('app', 'com.acme.crm')`. +- 4e0838d: fix(spec,lint)!: an `objectExtensions` entry that leaves a `controlled_by_parent` object with anything but one required `master_detail` is refused, as the same object declared directly already was (#23210) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0055 §1: a `controlled_by_parent` object derives its master from exactly one `master_detail` field declared `required: true`. `ObjectSchema` refuses an object that declares anything else, and so does the security-posture lint. Neither looked at the object the runtime actually runs: at boot the registry merges every `objectExtensions` entry onto its target, field by field, so an extension could add a second required `master_detail`, or demote the one master. `defineStack`, `os validate` and the lint passed the stack, and the runtime then derived no master: every read of the object denied, every write refused with `422 INVALID_METADATA`. + + **What is refused, and where.** + + - **`@objectstack/spec`.** `ObjectStackDefinitionSchema` (so `defineStack`, `os validate`, `os build`, the lint scorer and an artifact load) judges the rule on the merged field set of every `controlled_by_parent` object that an `objectExtensions` entry of the same stack extends. In a multi-package artifact (`packages[]`), an extension in any package judges an object any package declares. The merge is read as the registry computes it: fields by name, a later writer replacing the whole field, extensions in ascending `priority`. The issue sits at the `fields` of the extension that wrote the deciding field (`objectExtensions.0.fields`, or `packages.0.manifest.objectExtensions.0.fields`), with the message the same object gets when declared directly. `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **`@objectstack/lint`.** `security-controlled-by-parent-ambiguous-relation` and `security-controlled-by-parent-no-relation` judge the same merged shape and report it at the same extension, in the words of the declared object's finding. + + No export, signature or error code is added or removed. + + ## FROM → TO + + | stack | FROM | TO | + |:--|:--|:--| + | `objects: [{ name: 'cbpx_line', sharingModel: 'controlled_by_parent', fields: { header: { type: 'master_detail', reference: 'cbpx_header', required: true } } }, …]`, `objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'master_detail', reference: 'cbpx_other', required: true } } }]` | accepted; at runtime the object derived no master and failed closed | refused at `objectExtensions.0.fields`: "Object 'cbpx_line' declares sharingModel 'controlled_by_parent' with 2 required `master_detail` fields (`header`, `other`)…" | + | the same object, extended with `fields: { header: { type: 'master_detail', reference: 'cbpx_header', required: false } }` | accepted; no master at runtime | refused at `objectExtensions.0.fields`: "…but no required `master_detail` field…" | + | an artifact built before this change that carries either shape | loaded; the object failed closed | refused at load; rebuild it from the corrected source | + + **Remedy, in one line.** In the extension, drop the second master or declare it `type: 'lookup'`, and never redeclare the object's master with `required: false` or another type. + + **Unchanged.** + + - An extension adding any non-master field (a `lookup` to another parent included) to a `controlled_by_parent` object. + - An extension whose target the stack does not declare: another package's object, or the platform's. It is not judged, and the runtime's fail-closed refusal remains the backstop there. + - An object already refused as declared is reported once, at the object. + - In a multi-package artifact, a target on which two packages write one field at the same `priority`: the winner is the order the artifact loader registers them in, which the parse does not compute, so it is not judged. + - Stored `sys_metadata` rows: an extension is merged only when a stack or an artifact registers, so no stored row carries the shape and none is rewritten. + - The step-18 entry `cbp-extension-merged-master-refused` carries the prescription for `os migrate meta`. It is asked only of a stack that declares `objectExtensions`. +- 584e737: A hook can now tell a child delete that a parent's cascade issues from a hand delete: `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear` + + Clause-②: yes + + When a record is deleted and a relation whose `deleteBehavior` resolves to `cascade` (a `master_detail` unless it declares `restrict`, or a `lookup` / `tree` that declares `cascade`) removes its children, the engine deletes each child as the calling user. The child's `beforeDelete` and `afterDelete` hooks therefore saw the same `ctx.user`, `ctx.session` and `ctx.input` as a user deleting that one child by hand. A guard that freezes settled records, such as a quote's lines once the quote is accepted, could refuse both deletes or neither, and refusing both made the parent, and every record whose cascade reaches it, undeletable. + + - **What the key means.** `ctx.referentialCascadeDelete === true` exactly on a delete the engine's cascade issued: the child delete, in both phases, at any depth (a grandchild that the child's own cascade removes is marked too). It is absent, never `false`, on every other delete: the delete the caller asked for, a user's hand delete of the same child, and any write a hook issues through `ctx.api` during a cascade, deletes included. The `set_null` cleanup update keeps `referentialFieldClear` and never carries this key, and a cascade child delete never carries `referentialFieldClear`. + - **The guard idiom.** Refuse a hand delete of a frozen line and yield to the cascade: + + ```ts + if (ctx.referentialCascadeDelete !== true && ctx.previous?.locked === true) { + throw new Error('This line belongs to a settled quote and cannot be deleted.'); + } + ``` + + - **Only the declared key is the contract.** There is no operation-private (`__`-prefixed) spelling of this fact on the execution context. The engine recognises the delete its cascade issued through an engine-private record keyed on that child's object and id, so nothing on `ctx.api`, `ctx.transaction` or any `__` key can stand in for it. Read `ctx.referentialCascadeDelete`. + - **In a sandboxed hook body.** The key is carried across the QuickJS boundary by contract, so a lowered `*.hook.ts` body reads `ctx.referentialCascadeDelete` exactly as an in-process handler does. + - **spec (minor):** `HookContextSchema` declares `referentialCascadeDelete` (boolean, optional). One optional engine-produced key on the non-strict runtime hook context; nothing previously valid changes meaning. + - **objectql (minor):** `delete()`'s hook-context assembly sets the key on the delete the cascade walk issues. The walk still hands the child the caller's context unchanged, so no other hook-context member moves. + - **runtime (minor):** `buildSandboxContext` and `installCtx` carry the key into the VM. +- 301eec6: feat(spec)!: retire the object's `externalSharingModel` — the external org-wide default no principal ever read (#23268) + + **BREAKING** — `externalSharingModel` on an object (`ObjectSchema`, the `object` metadata type) is now refused at parse. ADR-0090 D11 declared it as a second org-wide default for portal and partner principals, and nothing ever evaluated it: no request resolves an external principal, and the authorization kernel's `EXTERNAL` rung (ADR-0095 / ADR-0099) admits explicitly shared rows only, never an org-wide default. Setting `externalSharingModel: 'private'` hid nothing from anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share (a manual share or a sharing rule), and an anonymous reader through a share link (`publicSharing`, unchanged). + + Clause-②: no (narrowing) + + Measured before removal, at `e4ec6ec1a`: no row-pipeline reader in any package. The key's only readers were authoring checks of its own ordering against `sharingModel` — the publish linter's alias and wider-than-internal branches, and the `plugin-security` posture gate's env-tighten comparison — plus the Studio console's object settings select (a separate console change). The only authored instances in this repository were two showcase objects. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `externalSharingModel: 'private'` (or `'public_read'`, `'public_read_write'`, `'controlled_by_parent'`) on an object | nothing — delete the key; share rows explicitly where an outside principal needs them | + | `import { SECURITY_EXTERNAL_WIDER } from '@objectstack/lint'` | nothing — the rule `security-external-wider-than-internal` is removed with the key it judged | + + **The one-line fix:** delete `externalSharingModel` from every object. + + **What an author who still writes it sees.** `tsc` fails at the authoring site (`ObjectSchema` types the key as retired), and the parse — `ObjectSchema.create()`, `defineStack({ objects })`, `PUT /api/v1/meta/object/:name` (draft saves included) — refuses it at its own path with the prescription: + + > `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The retirement kit + + - **A `retiredKey()` tombstone** on `ObjectSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `data/Object:externalSharingModel`. + - **The D2 conversion `object-external-sharing-model-removed`** (protocol 18, retired from the load path) deletes the key from every `objects[]` entry, one notice per object, as a lossless delete. Studio's object settings offered the select and `PUT /api/v1/meta/object` persisted it until this release, so a stored `object` row can carry it; the rehydration seams replay this conversion over stored rows, and `os migrate meta --stored` lists the rows that carry it before the upgrade. + - **The D3 entry `object-external-sharing-model-retired`** asks the author whether anything they built assumed the key kept outside users away from an object's rows. Nothing ever enforced that. + - **Ledger:** the row goes `planned` → `dead`, kept as a tombstone row (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/lint`:** the `security-external-wider-than-internal` rule and its exported `SECURITY_EXTERNAL_WIDER` id are removed, and `security-owd-alias` no longer reads `externalSharingModel`; the parse refuses the key on every parsed door. On an unparsed intake (`os lint` on a raw config) the ledger-driven advisory reports it as `liveness-dead-property`. + + **`@objectstack/plugin-security`:** the object posture gate's env-tighten-only rule (R1) compares `sharingModel` alone; the external half left with the key. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 5547ad4: feat(spec): export the `controlled_by_parent` exactly-one-master predicate, so a producer asks the spec instead of restating ADR-0055 §1 (#23272) + + Clause-②: yes + + `@objectstack/spec/data` now exports the predicate `ObjectSchema` already refuses with: + + - `controlledByParentMasterRefusal(objectName, sharingModel, fields)` answers "does this object satisfy the exactly-one-master rule?". `undefined` means yes, or that the rule does not apply (another sharing model, or no field map). Otherwise it returns the refusal: the parse issue's `path` (`fields` or `sharingModel`), its `message`, the object's `shape` (`several-required-masters`, `several-masters`, `master-not-required`, `required-lookup-only` or `no-master`), and the `master_detail` and required `lookup` fields it declares. Types: `ControlledByParentMasterRefusal`, `ControlledByParentMasterShape`. + - `isControlledByParentMasterField(field)` answers "does this field count as the master?": a `master_detail` field declared `required: true`, and nothing else. The object satisfies the rule when exactly one of its fields counts. + + A producer that defaults an object to `controlled_by_parent` asks `controlledByParentMasterRefusal(name, 'controlled_by_parent', fields) === undefined` with the field map it is about to write. The answer is the parse's: on every shape the v18 upgrade lists, the export and `ObjectSchema` agree on the verdict, the issue path and the message. + + `@objectstack/lint`: `security-controlled-by-parent-no-relation` and `security-controlled-by-parent-ambiguous-relation` count masters with the exported field predicate instead of their own copy. Their findings, paths and texts are unchanged. A `master_detail` that names no `reference` still does not count for the lint. + + No export is removed, and no accepted or refused shape changes. +- 0f05907: feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account, the forced reset gets its own code and an explicit revocation switch + + Clause-②: yes (narrowing) — the published contract widens (the posture codes become a closed vocabulary with a declared scope, a new `PASSWORD_RESET_REQUIRED` code, a per-user posture member on the auth service contract, new `@objectstack/core` exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served). + + + + **BREAKING** (an accept-set narrowing at the API-key door, and a changed code for the administrator-forced password change), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling C′ on #23287: posture codes are a closed vocabulary, and each code declares what it holds. + + - **The vocabulary and its scope (`@objectstack/spec/kernel`).** `AuthGateCodeSchema` is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES` declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). `ExecutionContext.authGate.code` is typed against the vocabulary, and `PASSWORD_RESET_REQUIRED` is registered for `@objectstack/plugin-auth` in the error-code ledger. + - **One per-user posture computation (`@objectstack/plugin-auth`, `IAuthService.resolveUserAuthGates`).** It answers every posture a user holds, most urgent first, keyed by user id and the organization the credential operates in. The session gate shows its first entry, as before. + - **One judgment at the shared key-admission seam (`@objectstack/core`).** `resolveApiKeyAdmission` asks that computation for the key's owner and holds the key on an account-scope posture (a new `held` outcome). `resolveAuthzContext` resolves a held key as a held principal: the owner, `credential: 'api_key'`, and the posture on `authGate`. Both new inputs are optional (`userAuthGates`), and `userAuthGateSourceOf(authService)` binds one from an auth service. + - **REST and the runtime dispatcher** judge a key-admitted request by the key's answer, and re-read the session cookie for the gate only when a session admitted the caller. The declared-endpoint fallback does the same. + - **The raw-host families** take the same answer through the shared seam: datasource admin, share links, settings, and the storage family's door. The webhook-redeliver door admits no API key at all, and still does not. + - **The forced reset (`POST /api/v1/auth/admin/set-user-password`, the `set_user_password` action).** `must_change_password` now answers `PASSWORD_RESET_REQUIRED` instead of reusing `PASSWORD_EXPIRED`. The act carries `revokeSessionsAndKeys`, **on by default**: it ends every live session of the user and revokes every API key they own, and the response reports what landed (`revoked: { sessions, apiKeys, failed, complete }`). + + What changes for you: + + - **Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED`, with the message the session gets, on every face that admits keys. It works again the moment the owner enrols, with nothing reissued: the refusal is not a revocation. + - **Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working. A session sent beside an API key no longer answers for the key. + - **A forced password change answers `PASSWORD_RESET_REQUIRED`.** A client that recognises `PASSWORD_EXPIRED` to show its change-password flow should recognise `PASSWORD_RESET_REQUIRED` the same way. + - **An administrator's password reset now signs the user out everywhere and revokes their API keys by default.** For a routine reset, pass `revokeSessionsAndKeys: false` (or clear the switch in the Set Password dialog). Revoked keys stay revoked after the password change; an administrator can restore a key a routine reset revoked with the Restore API Key action, or the owner mints a replacement. + - **Hosts calling `resolveAuthzContext` or `resolveApiKeyAdmission` directly** pass `userAuthGates: userAuthGateSourceOf(authService)` to have the key's owner asked. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `held` outcome. +- b1e9280: feat(spec): the seed loader reports the rows each dataset landed on, and the seed-settlement contract hands them to readers + + - **`SeedLoadResult.landedIds`** (`@objectstack/spec/data`, default `[]`): the ids of the stored rows a dataset's records landed on — a row a record inserted, a row it updated, and a row its key matched on a replay and left as it was. Each id appears once, in record order. A dry run reports none. A replay therefore reports the rows an earlier load wrote, so the answer holds across boots. The loader's own key and reference resolution is behind it: a dataset keyed on a reference reports the matched row, whose stored key is the resolved id, never the dataset's value. + - **`ISeedSettlementService.seededRecordIds?(object, organizationId?)`** (`@objectstack/spec/contracts`, optional): "which rows of this object did this process's seed write?". Without `organizationId` it answers for the boot-time seed sources (the inline seed `app:seeded` announces); a source publishes its rows before it settles, so a reader inside `app:seeded` already sees them. With `organizationId` it answers for that organization's per-organization replay; the host keeps the most recent replay only, so a process that creates many organizations does not hold every replay's rows. A host that does not implement it publishes no reading, and a reader treats every unowned row as one it cannot place. + - **`@objectstack/metadata-protocol`**: `SeedLoaderService.load()` fills `landedIds` on every per-dataset result. + - **`@objectstack/runtime`**: the `seed-settlement` service implements `seededRecordIds`. `AppPlugin` publishes the inline seed's rows before the source settles (the loader path and the basic-insert fallback alike), and its per-organization `seed-replayer` records each replay's rows under that organization. `SeedSourceHandle` gains `publishLanded(report)`. + + Nothing is removed or renamed. The reader this exists for is the seed-ownership claim, which now hands over only the seed's rows (see the `@objectstack/plugin-security` and `@objectstack/organizations` entries). +- 05ab9ef: feat(spec)!: the page parse refuses a page component's `data` prop that names the retired view-level `api` data source, so the Studio, REST and MCP save door refuses it as `os validate` does (#23357) + + **BREAKING** — a page whose `object-grid`, `object-map`, `object-gantt` or `object-tree` component carries `properties.data` with `provider: 'api'` is now refused by `PageSchema`, at the component's `properties.data.provider`, with the retirement's own prescription. Those four components take a view data source (`ViewDataSchema`) as `data`, so the step-18 retirement of the view-level `api` source reached them through the same schema; but the page parse leaves a component's `properties` an open record, and the component-props gate that refused the binding runs at the CLI doors only. A page saved through the Studio, REST or MCP therefore kept the binding, with its request headers, and the console kept sending it. + + Clause-②: yes (narrowing) + + The judge is the retirement's own: `ViewDataSchema` parses the component's `data`, and the prescription it raises is raised by the page parse unchanged (one text for a view and a page; the page's issue `code` is `custom`, as every object-level check on `PageSchema` raises). It reaches every component under `regions` and `slots`, at any depth, a component nested in a `page:tabs` item or a container included. It judges nothing else in the bag: another `data` provider, a component whose `data` is a row array (`object-kanban`, `object-calendar`, `object-timeline`), a custom or registered type with no props row, and a component's own `dataSource` binding are untouched. `checkPageComponentViewData` is exported from `@objectstack/spec/ui`, like the four other `PageSchema` checks, so a mirror built from `PageSchema.shape` can re-attach it. + + ## FROM → TO + + | you wrote (accepted by the page parse until this change) | write instead | + | --- | --- | + | `properties.data` with `provider: 'api'` (and its `read` / `write` request blocks) on an `object-grid`, `object-map`, `object-gantt` or `object-tree` page component | `properties.data` with `provider: 'object'` and `object: ''`, or `provider: 'value'` with `items: [...]`; an endpoint the page must read is a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }`, same-origin and with no credential key | + | `read.headers: { Authorization: '...' }` inside that binding | nothing: a credential never lives in metadata, and the console reads an element binding with its own signed-in fetch | + + **The one-line fix:** bind each such component to its object or to static rows, and move any endpoint read onto a page component's `dataSource`. + + **What an author who still writes it sees.** `definePage()`, `defineStack({ pages })`, `os validate` and `PUT /api/v1/meta/page/:name` refuse the page at `regions[i].components[j].properties.data.provider` (or the nested or slot position) with the prescription. At the save door that is `422 INVALID_METADATA`, nothing persisted. + + **A page already stored with such a component.** `os migrate meta --stored` lists it by page and component (the step-18 entry `view-data-api-provider-retired`), and nothing rewrites it: deleting `data` would make the component query its object instead. Left as stored, the page still loads and is served, registered under `[metadata_spec_invalid]` with the refusal in the `_diagnostics` of every read, and its next save is refused with 422. The console stops sending the request when its own reader of the retired arm is deleted (objectstack-ai/objectui#12325); until then a stored page keeps sending it. + + +- 1d79556: feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: a 90-day default expiry, a one-year ceiling, a 90-day inactivity expiry, a one-year grace for keys created with none, and distinct refusal codes + + Clause-②: yes (narrowing) — the published contract widens (four `auth` settings keys, the `IAuthService.resolveApiKeyLifecyclePolicy` member and its `ApiKeyLifecyclePolicy` type, four registered error codes, new `@objectstack/core` exports) and the API-key doors narrow (a signing past the ceiling is refused; an inactive key, a key past its grace, and a revoked or expired key sent beside a session are refused where they were served). + + + + **BREAKING** (an accept-set narrowing at the API-key doors), shipped as `minor` under the launch-window convention for breaking changes. + + Ruling A on #23367: the key store stays `sys_api_key`, and the policy is auth configuration. + + - **The policy (`@objectstack/plugin-auth`, `IAuthService.resolveApiKeyLifecyclePolicy`).** Three `auth` settings keys, bound by `bindAuthSettings`: `api_key_default_expiry_days` (90), `api_key_max_expiry_days` (365) and `api_key_inactivity_days` (90; `0` turns it off). A fourth, `api_key_policy_started_at`, is recorded once — on the first boot that runs the policy, at `kernel:bootstrapped` — and anchors the two clocks below; it survives restarts and is never overwritten by the platform. + - **The signing door (`POST /api/v1/keys`).** A signing that names no `expires_at` gets the default life and the response echoes it. An `expires_at` further away than the ceiling is refused `400 API_KEY_EXPIRY_TOO_LONG`, naming the latest date allowed; exactly the ceiling is accepted. A kernel with no auth service signs as before. + - **The key-admission seam (`resolveApiKeyAdmission`, `@objectstack/core`).** A real key its own state refuses is a new outcome, `lifecycle`, with one of three distinct codes: `API_KEY_REVOKED`, `API_KEY_EXPIRED` (past `expires_at`, or past the grace of a key created with none) or `API_KEY_INACTIVE` (no recorded use for longer than the window). `resolveAuthzContext` raises it as the branded `ApiKeyLifecycleRefusalError` (`403`, a located message naming the key's prefix, the date and the remedy). An unknown key is still `none`. + - **`last_used_at` is written.** Its description promised "Automatically updated on each API call", and nothing wrote it. The seam now stamps it on an admitted (or held) call, at most once an hour per key, and the inactivity judgment reads the value as it was BEFORE that call's stamp, so a key's own use cannot revive it. A stamp that fails never fails the request; it is said once, at `error`. + - **Keys created before the policy.** A key with no expiry gets one year from `api_key_policy_started_at`; the date is stamped onto its `expires_at` the first time it is admitted, so the keys page shows it, and the refusal after it names it. A key with no recorded use counts its inactivity from the same anchor (or from its own creation, when later) — never from an earlier `created_at`, since no use was ever recorded before the policy. + - **Every face that admits keys** passes the policy source (`apiKeyLifecyclePolicySourceOf(authService)`, a required input beside `userAuthGates`). Every one of them answers the refusal's `403` and code: the runtime dispatcher (the MCP HTTP endpoint among its routes), the REST data API, the settings routes, the storage download routes, the datasource admin routes, the share-link routes, the marketplace install-local routes and the MCP stdio transport. Each relays the refusal by its brand, so every other identity fault at those doors keeps its existing answer. + - **`OS_CLOUD_API_KEY` is untouched.** It is the runtime's outbound credential to the control plane, never admitted by this seam. + + What changes for you: + + - **A key created without an expiry now expires.** It used to live until revoked; it now lives 90 days (or the configured default). Pass `expires_at` to choose, and create a new key before it ends. + - **A signing asking for more than a year is refused** `400 API_KEY_EXPIRY_TOO_LONG`, where it used to be accepted. Ask for the date the message names, or an earlier one. + - **A key unused for 90 days is refused** `403 API_KEY_INACTIVE`, where it used to be served. It is not revoked; the remedy is a new key. + - **A key that already existed with no expiry keeps working for one year** from the date the policy took effect on your deployment (`auth.api_key_policy_started_at`), then answers `403 API_KEY_EXPIRED` naming that date. Replace such keys within the year. + - **A revoked or expired key answers its own code** (`403 API_KEY_REVOKED` / `API_KEY_EXPIRED`) on every face that accepts a key, where it used to answer the generic `401`; and a session cookie sent beside such a key no longer answers for it. + - **Hosts calling `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** pass `apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService)` (or the sixth argument), or an explicit `undefined` where no auth service exists. A caller of `resolveAuthzContext` that wants the located answer re-raises `ApiKeyLifecycleRefusalError` (`isApiKeyLifecycleRefusalError`) the way it re-raises the store outage. Code switching exhaustively over `ApiKeyAdmission['outcome']` handles the new `lifecycle` outcome. +- 8693110: feat(spec)!: two page-property conversions leave the load path at 18 — a `page:header`'s `description` and an inline `api` action's object-form `params` are refused with their rename at every door that judges page props + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** the ADR-0087 D2 conversions `page-header-subtitle-alias` and `inline-action-api-params-to-body-extra` were declared for protocol 17's one-major window and are now retired from the load path. The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites either spelling, so an author who still writes one meets the page-props refusal, which names the rename. That refusal is the same answer the metadata save door gives, so `os validate` no longer passes a page that the save door then refuses. Both refusals now also name the release that refuses the old spelling and the `os migrate meta` command: the `page:header` `description` refusal is an upgrade prescription naming `subtitle` (it was a bare "did you mean"), and the object-form `params` refusal adds the release and the command after its `bodyExtra` instruction. + + Migration: + + - `page:header` (and the legacy `page-header` node) `properties.description` → `properties.subtitle`. Rename the key; the value is unchanged. If both keys are present with different text, keep the one you mean under `subtitle` and delete `description`. + - An inline `type: 'api'` action's object-form `params` (a request payload map such as `params: { name: '{{page.inquiryName}}' }`) → `bodyExtra`. Rename the key; the value is unchanged. `params` stays the `ActionParam[]` definition array (the fields a dialog collects). + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + Stored metadata needs no edit. Stored-row rehydration still replays the whole conversion chain, these two entries included, so `sys_metadata` page rows written under either old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door. + + +- ad5a7d7: feat(spec)!: a page action row is judged by `ActionSchema`'s declarative-update rules, from the one judge both read + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings. + + The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards `operation`, `patch`, `params`, `target`, its executor key and `locations` to the console's action runner, which reads `operation` before any executor: `operation: 'update'` posts the write through the platform action route and runs no executor of its own. `ActionSchema` refused the shapes below on an action, and the rows accepted them. The rows now apply the same judge (`ActionSchema`'s texts are unchanged), with the executor spelled as the row spells it (`actionType` on a node, `type` on a member): + + - `patch` without `operation: 'update'` is refused at `patch` (it is never written); + - beside `operation: 'update'`: + - an executor other than `'script'` is refused at the executor key; + - `target`, `method`, `bodyExtra`, `bodyShape` and, on `action:button`, `recordIdField` are each refused at their own key; + - a row with nothing to write is refused at `patch`; + - a `list_toolbar` location is refused at `locations.N`; + - a row's `patch` is `ActionSchema`'s own record of field values: a string, array or number `patch` is refused at `patch` (the runner dropped it before the write). + + On `action:button` and `action:icon`, a non-empty object `params` (the node's static values, which the write merges over `patch`) counts as something to write. On a container member it does not. Beside the operation, the rows' url-target and `'navigation'` rename refusals stand down, because the update's refusal of `target` and of the executor covers both keys. + + ### FROM → TO + + | before (on a row) | what to write instead | + | --- | --- | + | `{ actionType: 'script', patch: { status: 'done' } }` (no `operation`) | `{ operation: 'update', patch: { status: 'done' } }` for the write, or drop `patch` | + | `{ operation: 'update', patch: { status: 'done' }, target: 'close_task' }` | the same row without `target`: the platform action route performs the write | + | `{ operation: 'update', patch: { status: 'done' }, actionType: 'url' }` (or `type` on a member, or any other executor) | the same row with no executor key, or `'script'` | + | `{ operation: 'update', patch: { … }, method: 'PATCH' }` (likewise `bodyExtra`, `bodyShape`, `recordIdField`) | the same row without that key (static field values go in `patch`) | + | `{ operation: 'update' }` (no `patch`, empty `params`) | add `patch: { … }`, or `params` that supply the values | + | `{ operation: 'update', patch: { … }, locations: ['list_toolbar'] }` | a record location (`list_item`, `record_header`, `record_more`, `record_section`), or a list view `bulkActionDefs` entry for the selection bar | + | `{ operation: 'update', patch: 'done' }` | `patch: { status: 'done' }` | + + A row that meant a call of its own keeps its executor and `target`, and drops `operation` and `patch` instead. + + **The one-line fix: write a page action row's declarative update as `ActionSchema` accepts it, with `operation: 'update'` and a `patch` record, no executor or `'script'`, no dispatch keys, and record locations only.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-declarative-update-rules`; the rewrite is by hand. + + **What an author now sees.** `os validate`'s props gate reports each refusal at the row's own key (`properties.…`, or `properties.actions.N.…` for a member), with `ActionSchema`'s text. For example, `{ operation: 'update', patch: { status: 'done' }, actionType: 'url' }` on an `action:button` gets ``… so `actionType` names that route or nothing; leave it off. …`` at `properties.actionType`. + + **Measured producers.** None in this repository: its examples, the CLI corpus, the dogfood fixtures and the package tests carry none, apart from these rows' own pins. Two landed pins put `api` or a host handler beside the update, and they now name `'script'`. In objectui, at its pin `b9814b334` and its main `524136e`, no page action row with these shapes is parsed by the spec. One renderer-test control mounts an `action:button` with `patch` and no `operation`, to show nothing is written. +- dd29bbe: feat(metadata-core,metadata-protocol)!: the `sys_metadata` family declares no organization column — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit` and `sys_metadata_history` (ADR-0131 D6/D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Metadata is the environment's (ADR-0131 D6). The four tables of the metadata family no longer declare `organization_id`, and each sets `systemFields.tenant: false`, so the engine injects none back. The engine refuses an undeclared field, so nothing reads or writes the column any more. + + FROM → TO: + + - An authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on one of the four tables → delete it; no organization owns a row. The field resolver answers it as an unknown field. + - `new SysMetadataRepository({ engine, organizationId, orgLabel })` → `new SysMetadataRepository({ engine, orgLabel })`. The `organizationId` option is gone: every repository reads and writes the environment's rows. + - `DatabaseLoader`'s `organizationId` option (and so `MetadataManager.setDatabaseDriver` / `setDataEngine`'s `organizationId` argument) → accepted and ignored, like `environmentId`: no write stamps it and no read filters on it. Drop the argument. + - `@objectstack/spec`: the published `ApiError.code` / `ErrorCode` enum widens by `LEGACY_ORGANIZATION_ROWS_UNREADABLE`, registered under `@objectstack/metadata-core`. + - `isLegacyOrganizationRow(row)` (`@objectstack/metadata-core`) → `readLegacyOrganizationRows(driver, table?)`. The row-value test read a key the engine no longer returns, so it is removed. The new read answers from the physical column, through the table's driver (`engine.getDriverForObject`): id → organization, empty on a migrated or fresh database. A seam that cannot answer is refused (`LegacyOrganizationRowsUnreadable`), never read as "none". + - An existing database → run `os migrate organization-ownership` (review its plan), then `os migrate organization-ownership --apply --plan-file FILE` with that plan file. The ceremony promotes or reports the legacy organization rows, then drops the physical column. Until it has run, the v18 runtime refuses to start against the database. + + The indexes are re-keyed with the column: + + - The overlay unique key is (`type`, `name`, `package_id`, `bundle_member`): the declared `idx_sys_metadata_overlay_active` and the runtime partial indexes of the same names. Removing the organization TIGHTENS uniqueness. On a database the ceremony has not cleared, two legacy organizations' rows, or an organization's row and the environment's, can share a key. There the runtime index migration keeps the previous index and names the duplicates (ADR-0120 D4); it never drops a row. `os migrate duplicates` and the boot report group by the same key. + - `sys_metadata_history` is unique on (`event_seq`) and on (`type`, `name`, `version`). Its other indexes, and those of `sys_metadata_audit` and `sys_metadata_commit`, lose their organization prefix. + - `idx_sys_metadata_org_type` is gone. + + What else changes: + + - The boot line `[metadata_org_scoped_unserved]` is gone with the column it read. A database still holding legacy organization rows is refused at boot instead, until the ceremony has run. + - The package uninstall no longer removes a legacy organization's row of the package's name: no row carries an organization. + - Who reads the family through the generic data door (`/data/sys_metadata_*`), before → after: + - `sys_metadata_audit`, `sys_metadata_history`, `sys_metadata_commit`, **under a wall** (`isolated` / `group`): before, the tenant wall filtered each caller to its own organization's rows, and an `organization_admin` read its organization's. With the column gone the wall has nothing to filter on, so without a gate that admin would read every organization's trail, history bodies and commit ranges. After: each requires `studio.access` (`requiredPermissions`, the #22107 precedent). The capability is platform-scoped, so an `organization_admin` holding it by role does not exist, and that admin is refused. + - The same three, **under `single`**: before, any caller whose grant covered the table (a read wildcard) read it. After: `studio.access` is required there too. A Studio author (the platform admin among them) reads as before; a plain member is refused. + - Every `/meta` door serving these rows admits `studio.access`: `/meta/:type/:name/history`, `/audit` and `/diff` admit `studio.access` or `manage_metadata`, and `/packages/:id/commits` admits `studio.access` alone; `setup.access` alone is refused at all four. `requiredPermissions` is an AND-gate and cannot name an any-of set, and `setup.access` is held by organization admins, so it could not be the gate. A caller holding only `manage_metadata` therefore still reads the history and audit rows through `/meta`, but not the commit rows, and none of them through the data door. + - `sys_metadata` stays ungated, before and after: its rows are what `/meta` already serves to every organization. + - On a database the ceremony has not migrated (which `os serve` refuses, but every `os migrate` boot opens, the ceremony's own among them), the legacy organization rows are still told apart, off the physical column: + - every read the metadata protocol makes of the family goes through one seam that leaves them out. That covers boot hydration; the `/meta` list, by-name, layered and draft-preview reads; the item lock (`_lock`) the reads report and the save and delete gates enforce; the package-less write and read address; and the package, commit, audit, history and diff reads. Two reads keep them on purpose and read the column themselves: `os migrate meta --stored` reports such a row, and the commit revert refuses a legacy commit by name. The authored hook and action binders leave them out too; + - the metadata repository's write never resolves to one: before, a save of an address that only a legacy organization's row held rewrote that row in place (measured on the ceremony's template promotion). Now the write targets the environment's row, and with none it creates one, which the re-keyed unique index refuses beside the organization's row until the ceremony settles it; + - `os migrate meta --stored` reports such a row `skipped` and never re-saves it; + - `os migrate security-catalog-overlays` neither lists nor deletes it; + - a commit recorded in a legacy organization's layer is still refused by the revert; + - the runtime-datasource reads, the packaged permission-set overlay reading, the kernel:ready security-catalog echo, the stored flow-credential move and a `DatabaseLoader` (engine- or driver-backed) leave them out. + - `SqlDriver.listPhysicalColumns(object, options?)` (new, read-only): a table's physical column names, from the catalog, joining `options.transaction`. `TursoDriver`'s remote face answers it from the remote catalog. Every boot asks whether the family still carries the retired column, and it asks this way, not through `execute()`. So `os migrate plan` keeps its "a plan writes nothing" line on a migrated or fresh database. The legacy rows themselves are read with a raw statement only where the column exists. + - `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. A withdrawal saved only at the organization layer is carried onto the environment by the ceremony's `metadata-promotion` step, which the runtime requires before it starts. + - The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. +- dd29bbe: feat(runtime,cli)!: `os serve` refuses to start against a database still in the v17 organization-ownership shape, and `os migrate apply` refuses to drop its `organization_id` columns, until `os migrate organization-ownership` has verified it (ADR-0131 D10 item 5) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + A v18 runtime no longer serves a database it was never migrated for. At `kernel:ready`, `os serve` (and so `os dev`) checks every table whose object declares no organization column (`systemFields.tenant: false`). If one still carries a physical `organization_id`, the boot is refused unless the `sys_migration` row `adr-0131-organization-ownership` is verified. The refusal names the tables and the command. It is thrown before `kernel:listening`, so no request is served. Its code is `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`. + + FROM → TO: + + - A 17.x database started by an 18.x `os serve` → run `os migrate organization-ownership`, review its plan, then `os migrate organization-ownership --apply --plan-file FILE` with that plan file, and start again. To keep 17.x semantics on an unmigrated database, run a 17.x runtime. + - `os migrate apply --allow-destructive` on such a database (`@objectstack/cli`, BREAKING `minor`: the command narrows) → it now refuses to drop `organization_id` from any table (the D7 tables and the `sys_metadata` family among them) while the ceremony's marker is not verified. Before, the drift report offered each such column as an unmapped destructive column, and the apply dropped it. Dropped ahead of the ceremony, that column takes the ceremony's promotion and report steps with it, turns every legacy organization row into an environment row, and leaves the boot refusal no column to find. The refusal names `os migrate organization-ownership`, exits 1, and under `--json` answers `message: 'refused_organization_ownership_unmigrated'` with the `tables`, as the `sys_account.issuer` (#17440) and `sys_comment.reactions` (#23281) refusals do. The refusal holds the WHOLE `--allow-destructive` run, as those two do: nothing in it is created, applied or dropped, every other destructive drop and the additive work included. A run without `--allow-destructive` is unchanged. The ceremony drops these columns itself, in its column-drops step, before it records the run verified. + - There is no escape hatch: no `OS_ALLOW_*` or `OS_SKIP_*` variable and no flag skips either check. + + What does not change: + + - A fresh database boots: no table it creates carries a retired column, so no marker is needed. + - A database whose ceremony verified boots, whatever its columns. + - The `os migrate` commands boot as before, the ceremony's own among them: the boot guard is composed by `os serve` only, never by the shared standalone stack (`createStandaloneStack`, which `bootSchemaStack` and every other CLI data and schema boot build on). Only an `os migrate apply --allow-destructive` run that would drop an `organization_id` column is refused, as above. + - A driver with no physical columns to read (memory, MongoDB) is not checked. + - A host that composes its own kernel can add the guard itself: `OrganizationOwnershipBootGuardPlugin`, or `assertOrganizationOwnershipMigrated(engine)` from `@objectstack/runtime`. `ORGANIZATION_OWNERSHIP_MIGRATION_ID` is now exported from `@objectstack/spec/system`. +- 6f21216: feat(plugin-security,cli)!: `sys_user_permission_set.permission_set_id` is retired, the boot-time grant-name backfill is deleted, and a grant is unique per user, set and organization (ADR-0131 D4/D10) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + A permission-set grant names its set by `permission_set`, its one reference (ADR-0131 D4). The legacy id column is no longer declared, and the grant's uniqueness now keys the name. + + FROM → TO: + + - `sys_user_permission_set.permission_set_id` → `permission_set`, the machine name of the set the grant holds. A seed row or data-door write that still carries `permission_set_id` is refused with `400 INVALID_FIELD`. Fix: delete the key and name the set, `{ user_id, permission_set: '', organization_id }`. + - The unique index (`user_id`, `permission_set_id`, `organization_id`) and the (`permission_set_id`) index → one unique index on (`user_id`, `permission_set`, `organization_id`), declared `unique: 'organization'`: its organization key part is NULL-safe (ADR-0120 D3), so it refuses a second grant of one set to one user in one organization AND a second platform-wide grant (organization NULL), for every writer and every write path — batches, multi-row updates, concurrent writes and raw SQL included. The data doors answer `409 UNIQUE_VIOLATION` (the engine throws `DuplicateRecordError`, `DUPLICATE_RECORD`). Fix: keep the grant that exists; to change it, update that row. + - The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → the v18 ceremony: plan it with `os migrate organization-ownership --out plan.json`, review the file it writes, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then. + + On a 17.x database every grant names nothing until `--apply` names it, so between the first v18 boot and `--apply` no grant confers anything, the platform administrator's included. + + What else changes: + + - The plan run, `os migrate organization-ownership --out plan.json`, reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL): + - `duplicates`: groups of named grants holding one set for one user in one organization. `--apply`'s `column-drops` step does not drop `permission_set_id` while one group remains. + - `rewriteCollisions`: legacy grants whose derived name their user already holds in the same organization. `--apply`'s rewrite names none of them and blocks, naming both ids. + - On a database without the `permission_set` column it counts zero and names the remedy (boot this release once), instead of refusing the plan. + + The ceremony deletes no grant: remove the one you do not keep, then re-run. + - The organization-administrator reconcile and its boot sweep write no grant for a pair holding an unnamed grant in that organization: the ceremony's rewrite names that grant, and a named grant beside it would hold the key the rename needs. + - `--apply` drops the two retired indexes before the column, in a separate earlier schema-drift apply: MySQL refuses to drop a column a key still uses. + - The organization-administrator reconcile and its boot sweep, the platform-admin bootstrap's unnamed-grant leg (Leg C) and the catalog reference report read grants by name only. A legacy grant that names nothing confers nothing, still withholds the platform-admin promotion while it is unscoped, and is reached by no revocation until the ceremony's rewrite names it; once named, the boot sweep revokes it where its pair no longer qualifies. The report's `UnnamedGrant` entries lose `permissionSetId`. + - `GET /meta/object/sys_user_permission_set` no longer serves the hidden `permission_set_id` field. +- 5fd2c76: feat(spec)!: seven overdue ADR-0087 D2 conversions leave the load path at 18 — six flow-node config aliases and a sharing rule's `accessLevel: 'full'` are refused with their rename at every door that judges a flow or a sharing rule + + Clause-②: no (narrowing) + + **BREAKING (authoring):** seven ADR-0087 D2 conversions whose one-major window had already run are now retired from the load path: `flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-map-flow-alias`, `flow-node-subflow-flow-alias`, `flow-node-script-config-aliases` and `sharing-rule-access-level-full-to-edit` (protocol 17's window), and `flow-node-crud-filter-alias` (protocol 11's). The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites these spellings. An author who still writes one meets the refusal of the surface's own contract, and every one of those refusals is now the upgrade prescription: it names the canonical key and the release that refuses the old spelling. The flow-node prescriptions used to say "the conversion rewrites it at load". `accessLevel: 'full'` used to get zod's bare "Expected one of: read, edit"; `SharingLevel` now names `edit`. + + Migration: + + - CRUD node (`get_record` / `create_record` / `update_record` / `delete_record`) `config.object` → `config.objectName`, and `config.filters` → `config.filter`. Rename the key; the value is unchanged. + - `notify` node `config.to` → `recipients`, `config.subject` → `title`, `config.body` → `message`, `config.url` → `actionUrl`, and a nested `config.source: { object, id }` → the flat pair `sourceObject` / `sourceId`. Rename (or move) the key; the value is unchanged. + - `map` and `subflow` node `config.flow` → `config.flowName`. Rename the key; the value is unchanged. + - `script` node `config.functionName` → `config.function`, and `config.input` → `config.inputs`. Rename the key; the value is unchanged. (`connectorConfig.input` on a `connector_action` node is canonical; leave it.) + - Sharing rule `accessLevel: 'full'` → `accessLevel: 'edit'`. `full` never granted more than `edit`. + - If a config carries both spellings with different values, decide which one you mean, keep it under the canonical key, and delete the old one. + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. (`config.filters` belongs to protocol 11, below the migration floor, so `os migrate meta` does not list it: rename that one by hand.) + + Stored metadata needs no edit. The automation engine's flow rehydration and stored-row rehydration still replay the whole conversion chain, these seven entries included, so `sys_metadata` flows and sharing-rule rows written under an old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door. + + The metadata save door's flow fallback now reaches the canonicalizer's verdict for every retired flow shape. When no automation service is reachable (or its canonicalizer throws), `saveMetaItem` judges a flow body after replaying the full conversion chain the automation engine's canonicalizer replays, retired entries included (the default flip `flow-decision-mode-inclusive-explicit` excepted, as there). That covers the seven entries retired here and those retired earlier that walk a flow: `flow-inert-keys-removed` (`flow.active`, `flow.template`, `nodes[].outputSchema`, `errorHandling.fallbackNodeId`) and `flow-node-wait-timeout-keys-removed` (`waitEventConfig.timeoutMs` / `onTimeout`). On such a host a body carrying one of those older keys was refused 422 by its tombstone before; it is now healed for the verdict, which is what a host with an automation service already did. What is stored stays the body as submitted, so a host's verdict on a saved flow no longer depends on whether it runs an automation service. + + Eight more entries below 18 stay on the load path, each with the reason recorded in its docblock: `flow-node-http-callout-rename` and `page-kind-jsx-to-html` (the old value is still accepted by its schema), `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (the schema still declares and folds the deprecated key), `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` (the node's `config` has no contract to refuse the loose keys), `datasource-driver-mongo-to-mongodb` and `retry-policy-converged`. + + +- be48c5e: feat(spec)!: retire the delegated-admin scope's `manageBindings` and `authorEnvironmentSets` — rights no door honoured (#23488) + + **BREAKING** — `manageBindings` and `authorEnvironmentSets` on a permission set's `adminScope` (`AdminScopeSchema`, ADR-0090 D12) are now refused at parse. They named the right to bind sets to positions and the right to author environment permission sets, and they gated the `sys_position_permission_set` and `sys_permission_set` rows, which ADR-0131 D3 retired. A position's sets are part of its definition and a permission set is a definition; both are written through the metadata door, which admits a `manage_metadata` holder and reads no `adminScope`. So a delegate who "may bind" or "may author" was granted nothing. Delete both keys: a delegate's scope is placement, assignment and the allowlist. + + Clause-②: yes (narrowing) + + Measured before removal, at `cdc42eb49`: no production author set either key to `true` (the showcase wrote the default `false`; the control key `manageAssignments: true` has four production authors), and no door that writes a position or a permission set consulted either key. Their one reader was the delegated-admin gate's strict-containment comparison, which changes below. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `adminScope: { …, manageBindings: true \| false }` | nothing — delete the key | + | `adminScope: { …, authorEnvironmentSets: true \| false }` | nothing — delete the key | + | a delegate who re-composes what a position distributes, or authors a set's body | a `manage_metadata` holder writes the position or set definition through the metadata door (ADR-0131 D3); the delegate assigns people to positions and grants allowlisted sets inside its subtree | + | reading `scopes[].manageBindings` / `scopes[].authorEnvironmentSets` from `GET /api/v1/security/my-delegable-scope` (`DelegableAdminScope`) | nothing — the report carries `manageAssignments` as the scope's one action right | + + **The one-line fix:** delete `manageBindings` and `authorEnvironmentSets` from every `adminScope`. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`AdminScope` types both keys as retired), and the parse — `definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name` — refuses it at its own path with the prescription: + + > `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + + ## The containment change (`@objectstack/plugin-security`) + + The delegated-admin gate judges a delegate who hands out a set that itself carries an `adminScope` — a `sys_user_position` assignment of a position that distributes it, or a direct `sys_user_permission_set` grant — by **strict containment**. That comparison read both retired flags. It now reads `includeSubtree`, `manageAssignments` and the allowlist only: + + - a delegate whose own scope set either flag `true` can **no longer** hand out a scope otherwise EQUAL to its own — the inert flag used to count as the strictly-larger axis and let that lateral copy through the equal-scope refusal. It is refused `403 PERMISSION_DENIED` (`… would hand out an adminScope not strictly contained by yours`); + - a granted scope that set either flag `true` no longer counts as out-reaching a delegate that did not. + + `DelegatedAdminGate.describeDelegableScope()` (and so `GET /api/v1/security/my-delegable-scope`) no longer reports either flag. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `AdminScopeSchema`, a `strictObject` — so each refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/AdminScope:manageBindings`, `security/AdminScope:authorEnvironmentSets`. `DelegableAdminScope` (`@objectstack/spec/contracts`) drops both fields. + - **The D2 conversion `permission-admin-scope-binding-authoring-removed`** (protocol 18, retired from the load path) deletes both keys from every `permissions[].adminScope`, one notice per key, whatever they hold. Both keys carried the default `false`, so a 17.x parse materialized them in every declared scope; the rehydration seams replay this conversion over stored `permission` rows and built artifacts, and `os migrate meta --stored` lists the rows that carry them before the upgrade. + - **The D3 entry `permission-admin-scope-binding-authoring-retired`** records the containment change above: the judgement the strip cannot make for the upgrader. + - **`@objectstack/core`:** `permissionSetDefinitionFromRow` — the mapping `os migrate`'s legacy-row ceremony saves a `sys_permission_set` row through — drops both keys from the row's `admin_scope`. That save goes through `saveMetaItem`, which replays no conversion: measured, a 17.x row's body answered `422 INVALID_METADATA` at `adminScope.manageBindings`, and the same body without the keys saved. + - **`@objectstack/platform-objects`:** the permission-set form's `adminScope` help text (all four locales) names the scope's live keys only. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's two switches for these keys retire in an objectui follow-up. + + +- bc0538a: feat(spec)!: the datasource driver spelling `mongo` is retired — an authored `driver: 'mongo'` is refused with the prescription naming `mongodb`, `OS_DATABASE_DRIVER=mongo` is no longer a driver selection, and `datasource-driver-mongo-to-mongodb` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `mongo` was a second accepted spelling of the MongoDB driver id `mongodb` through 17: the loader rewrote an authored `datasource.driver: 'mongo'` to `mongodb` with a notice, and both boot hosts accepted `OS_DATABASE_DRIVER=mongo`. At 18 it is retired. `DriverType` refuses it by name at every door that parses a datasource (`defineStack`, `os validate`, `os build`, `os lint`, the metadata and datasource APIs): "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `driver: 'mongodb'`." The refusal is case- and whitespace-insensitive, and it is published in the JSON Schema too (a `pattern` on `driver`), so a document validated against `json-schema/**` is refused as well. Both boot hosts now refuse `OS_DATABASE_DRIVER=mongo` (and a standalone stack's `databaseDriver: 'mongo'`) with their "Supported drivers" list, which names `mongodb`. The ADR-0087 D2 conversion `datasource-driver-mongo-to-mongodb` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the spelling. + + Migration: + + - `datasource.driver: 'mongo'` → `driver: 'mongodb'`. Change the value; the driver it builds and the `config` contract it is judged against are unchanged. + - `OS_DATABASE_DRIVER=mongo` → `OS_DATABASE_DRIVER=mongodb`, or unset it and let a `mongodb://` or `mongodb+srv://` database URL select the driver. The same for `databaseDriver: 'mongo'` passed to the standalone stack. + - A script or integration that creates or updates a datasource through the metadata or datasource REST API with `driver: 'mongo'` → send `driver: 'mongodb'`. + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. The D3 entry `datasource-driver-mongo-alias-retired` lists what the chain cannot reach: environment variables, launch scripts and API callers. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` datasource row written as `mongo` (the Setup → Datasources form wrote that until the canonical id was renamed to `mongodb`) keeps loading as `mongodb`. The spelling also stays on the spec's driver CONTRACT face (`resolveDriverId('mongo')` still answers `mongodb`): a reader that judges a stored body as written, such as the read-path credential redactor, keeps judging a legacy row as mongodb. Measured with the spelling deleted outright, that redactor served all ten mongodb passthrough secrets of such a row in cleartext. + + `@objectstack/spec` also exports `RETIRED_DRIVER_ID_GUIDANCE` (retired spelling → its prescription), the table the refusal is derived from. `@objectstack/driver-mongodb`'s README no longer lists `mongo` as a recognised `OS_DATABASE_DRIVER` value. + + +- d176b0a: feat(spec)!: the loose `config` keys of a `wait` or `connector_action` flow node are refused, naming the `waitEventConfig` / `connectorConfig` key they belong on, and `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` leave the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** neither node type has a `config` contract: the `wait` executor reads only `waitEventConfig` and the `connector_action` executor only `connectorConfig`. Through 17 the loader lifted settings written under the node's `config` into that block with a notice. At 18 both ADR-0087 D2 conversions are retired from the load path (`retiredAfter: '17.7.0'`), and the flow parse refuses each such key where it is written, at every door that judges a flow (`defineStack`, `os validate`, `os build`, and `os lint` when the config calls it), a node inside a loop / parallel / try-catch region body included: "A `wait` node's `config.duration` was removed in @objectstack/spec 18 (ADR-0087 D2) … Move it to `waitEventConfig.timerDuration` …". The refused keys are exactly the ones the lifts moved: `eventType`, `timerDuration`, `duration`, `signalName`, `signal` and `timeoutMs` on `wait`; `connectorId`, `actionId` and `input` on `connector_action`. Any other `config` key on these node types is judged as before. + + Migration: + + - `wait` node `config.eventType` / `config.timerDuration` / `config.signalName` → the same key under `waitEventConfig`. + - `wait` node `config.duration` → `waitEventConfig.timerDuration`, and `config.signal` → `waitEventConfig.signalName`. + - `wait` node `config.timeoutMs` → `waitEventConfig.timerDuration`, as a string (`60000` → `'60000'`, or `'PT1M'`): it was only ever read as the timer duration. + - `connector_action` node `config.connectorId` / `config.actionId` / `config.input` → the same key under `connectorConfig`. + - If the block already sets the same key, the block's value is the one the node always ran with: delete the loose key. + + Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. The D3 entry `flow-sibling-block-loose-config-keys-refused` states the edit for upgraders. + + Stored metadata needs no edit. The automation engine's flow rehydration (`registerFlow`), stored-row rehydration and the metadata save door's flow verdict still replay both conversions, and each now DELETES a loose key its block already shadows (and a loose key holding `null`) instead of leaving it in place, so a `sys_metadata` flow carrying either shape keeps loading with the block alone. A `connector_action` node whose `connectorId` / `actionId` pair is complete in neither place is refused as before: no conversion can complete it. + + `@objectstack/service-automation`: comment-only changes in the `wait` and `connector_action` executors, which already read only their declared blocks. + + +- c5dd7fc: feat(spec)!: the page kind `jsx` is retired — an authored `kind: 'jsx'` is refused with the prescription naming `html`, and `page-kind-jsx-to-html` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `jsx` was the first name of the constrained-JSX page tier (ADR-0080), which ADR-0081 renamed `html`. Through 17 it stayed a declared `PageSchema.kind` member and the loader rewrote an authored `kind: 'jsx'` to `html` with a notice. At 18 it is retired: `PageSchema.kind` declares it through `enumWithRetiredValues`, so it is refused by name at `os validate`, `os build`, the metadata save door and `defineStack`, so under `os lint` too when the config calls it: "Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`." Writing it in TypeScript no longer compiles either. The ADR-0087 D2 conversion `page-kind-jsx-to-html` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the value. `COMPILED_PAGE_KINDS` is now `['html']`: `requires` is accepted on an `html` page only. + + Migration: + + - `kind: 'jsx'` → `kind: 'html'`. Change the value; the page's `source` is compiled and rendered exactly as before, and `requires` stays legal on it. + - A script or integration that saves a page through the metadata API with `kind: 'jsx'` → send `kind: 'html'`. + + `os migrate meta` does not list this edit: the rename belongs to protocol 11, below the migration floor, so make it by hand. The D3 entry `page-kind-jsx-retired` states it for upgraders. + + Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` page row written as `jsx` keeps loading as an `html` page, and a built artifact whose declared protocol floor is at or below `17.7.0` is still converted at the artifact-ingestion door. + + The readers that branched on the retired value dropped it: the metadata save door's html-page compile gate (`@objectstack/metadata-protocol`), the CLI's SDUI-manifest page count, and the lint page walk, `validateJsxPages` and the page-source styling rule (`@objectstack/lint`) now read `html` (and `react` where they did before) only. + + `@objectstack/platform-objects` publishes the Studio page form's `source` help text in four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`); it no longer names `jsx`. + + +- 462dff3: feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3) + + Clause-②: yes + + The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it. + + - **`@objectstack/driver-sql`** compiles `{ field: { $in: } }` to `field IN (SELECT id FROM WHERE AND )`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs. + - **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join. + - **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call. + - **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3). + - **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. + - **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types. + - **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows. +- 941c78a: feat(spec)!: `deriveRecordFlowSurface` derives a bounded modal (`size: 'auto'`) for a field-heavy create / edit / child task flow on desktop; a mobile posture keeps the full-screen modal (`size: 'full'`) + + A task flow (`create`, `edit`, `child-create`, `child-edit`) whose base surface is `'page'` (at or above `RECORD_SURFACE_PAGE_THRESHOLD` authorable fields) still opens as an overlay modal, but on desktop its size is now `'auto'`, so the renderer's own `lg` / `xl` bound applies instead of stretching the form to the window edge. With `viewport: 'mobile'` the derivation answers `size: 'full'` exactly as before. A heavy form with an embedded child or line-items table is not split out: it takes the same bounded modal on desktop. The `view` flow and every light-object flow are unchanged. + + Clause-②: no + + + + **BREAKING** (a published derivation's default changes), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you: a consumer that relied on `deriveRecordFlowSurface(def, flow)` answering `size: 'full'` for a field-heavy task flow on desktop now gets `size: 'auto'`. + + | call | FROM | TO | + |:--|:--|:--| + | `deriveRecordFlowSurface(heavyDef, 'create')` (also `'edit'`, `'child-create'`, `'child-edit'`; or `{ viewport: 'desktop' }`) | `{ container: 'overlay', surface: 'modal', size: 'full' }` | `{ container: 'overlay', surface: 'modal', size: 'auto' }` | + | the same call with `{ viewport: 'mobile' }` | `{ container: 'overlay', surface: 'modal', size: 'full' }` | unchanged | + + The one-line fix, for an object that genuinely needs the full-screen form on desktop: set `navigation.size: 'full'` on its list view (or `FormView.modalSize: 'full'`), which wins over the derived default. + +### Patch Changes + +- 0d3ea29: fix(plugin-security): the data-door write gates of the retired catalog row objects are removed, the boot report names positions that bind a permission set nobody serves, and the texts that still named the retired objects and modules are corrected (ADR-0131 D3/D4, C3 stage 8-post) + + Clause-②: no + + Stage 8 retired the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` row objects, so no write to them reaches the engine. This change removes what only guarded those writes. No export of any package's entry changes, and no request that reaches the server is answered differently. + + - **`@objectstack/plugin-security`.** + - Three of the security middleware's gates on those objects are gone: the package-provenance gate on `sys_permission_set`, the platform/package row gate on `sys_position` and `sys_capability`, and the curated capability-name gate on `sys_capability`. The delegated-administration gate no longer judges `sys_position_permission_set` bindings or `sys_permission_set` authoring; it still governs `sys_user_position` assignments, `sys_user_permission_set` grants and `sys_member`. The rules those gates carried still hold where the catalog now lives: `everyone` and `guest` are code-declared positions an environment cannot overlay, and a capability cannot be created or overlaid through the metadata door at all. + - The walled data-door refusal for those objects is deleted. Stage 8 already left it unwired, and no composition registers the objects it judged; the metadata door's walled refusal is unchanged. + - The internal duplicate-name refusal class that only the retired permission-set data door raised is removed. + - The packaged-permission-set lock's refusal no longer has a "choose a different name" variant: only the retired data door's create raised it. The metadata door's refusal text is unchanged, and the published signatures keep their `operation` parameter. + - The boot's catalog reference report lists one more finding, per position: a position definition whose `permissionSets` names a set the security catalog does not resolve, usually because the package that declared the set is uninstalled or disabled. A holder of such a position is granted nothing through that name. The line clears by itself at the next boot after the package is installed again. It is logged at `error` once per boot, like the report's other lines. + - **`@objectstack/platform-objects`** and **`@objectstack/plugin-security`** object metadata: the descriptions of `sys_invitation.positions`, `sys_user_position` (object and `position`), and `sys_audience_binding_suggestion`'s permission-set name no longer name the retired objects. The translated texts follow. + - **`@objectstack/spec`**: the `projectionApplied` and `materializeApplied` descriptions on the metadata save, publish and batch-publish responses give a current example (`datasource`, `flow`) instead of `permission` → `sys_permission_set`. `EvalUser.positions` describes the positions assigned through `sys_user_position`. `PermissionSet.description`'s doc comment no longer names the deleted projection. The `name` describes of `PositionSchema` and `PermissionSetSchema` say a name is unique within the security catalog the environment serves (ADR-0131 D3: there is no organization-level catalog), replacing "unique per organization", which read the retired rows' index. The error-code ledger drops `@objectstack/plugin-security`'s `UNIQUE_VIOLATION` row, which named the removed refusal; `UNIQUE_VIOLATION` stays registered by its other emitters. + - **`@objectstack/core`**: the doc comments of `positionDefinitionFromRow` and `permissionSetDefinitionFromRow` no longer describe the deleted boot backfills as current. + - **`@objectstack/metadata-protocol`**: the doc comments of the publish materializer, mutation projector and uninstall cleanup registries give current examples (`flow`, `datasource`, the grant revocation) instead of `permission` → `sys_permission_set`. +- f9b4217: feat(cli)!: `os migrate organization-ownership --apply` executes only the plan the operator reviewed (ADR-0131 D10) + + Clause-②: yes (widening) + + + + **BREAKING** (a narrowing of `--apply`'s accept set, released in 18.0.0-next.1), shipped as `minor` under the launch-window convention for breaking changes. It also widens the command: a new flag, `--plan-file`, new refusal reasons, and two new keys on the apply report. + + `--apply` now requires `--plan-file FILE`, the plan file a plan run wrote. Before it writes anything, it compares that file with the plan it computes, and refuses when any of these differs: + + - **The inventory digest** (`plan-digest-mismatch`). The fates it would run are not the ones the reviewed plan showed: a different `--fate-supplement`, or another release's inventory. Without this check, planning with supplement A and applying with supplement B ran B's fates unreviewed, including a mirror deletion A never listed. + - **The tenancy posture** (`plan-posture-mismatch`). The plan file records the posture it was computed under (`OS_TENANCY_POSTURE` / `OS_MULTI_ORG_ENABLED`), and the posture decides which rows attribution gives an owner and which it reports. A plan reviewed under a wall, which lists rows as reported, would otherwise give them owners when applied under `single`. + - **The database** (`plan-database-mismatch`). + + The refusal names both values and the remedy: run the plan again with the same flags (or run the apply under the plan's posture), review it, and pass the new file. No flag skips the comparison. + + A file that is not an organization-ownership plan, or that lacks its digest, posture or database, or cannot be read, is refused as `plan-file-unusable`. `--apply` without the flag is refused as `plan-file-required`. Both refusals come before the backup question and before the database is opened. + + A checkpoint recorded under another digest is no longer discarded silently. The reviewed plan supersedes it: every step runs again (each is idempotent), and the apply report names the discarded checkpoint in `restartedFrom`, beside `reviewedPlan`. + + ## FROM → TO + + | invocation | FROM (18.0.0-next.1) | TO | + |:--|:--|:--| + | `os migrate organization-ownership --apply --backup-taken` | performed the plan it computed at apply time | refused, `plan-file-required`: run `os migrate organization-ownership --out plan.json`, review it, then `--apply --backup-taken --plan-file plan.json` | + | `os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken` | performed the plan it computed with that supplement | refused, `plan-file-required`: plan with the same `--fate-supplement`, then add `--plan-file plan.json` | + | `--apply … --plan-file plan.json`, where the file was planned with another supplement, release, posture or database | (no such flag) | refused with `plan-digest-mismatch`, `plan-posture-mismatch` or `plan-database-mismatch`, naming both values | + + ```bash + os migrate organization-ownership --out plan.json # plan, then review plan.json + os migrate organization-ownership --apply --plan-file plan.json --backup-taken # perform exactly that plan + ``` +- 07b27b3: fix(spec): the two-required-masters `controlled_by_parent` refusal names its release ("since @objectstack/spec 18"), as the no-master refusal does. Message text only: the refused shapes and the prescribed fix are unchanged. +- d0c09e4: The metadata save door runs the two page component rules that `objectstack validate`, `objectstack build` and `objectstack lint` run. A page saved through Studio, REST `/meta` or an MCP/AI author whose components carry an undeclared, wrong-typed or retired property (`component-props-unknown-key`, `component-props-invalid`), or a component `type` inside a spec-reserved namespace that the platform does not declare (`component-type-unknown`), is refused with `422 INVALID_METADATA`, and each issue carries the fix the CLI already prints. Before, the save answered `200` and the renderer ignored the property or drew a placeholder. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on the metadata save door (`PUT /api/v1/meta/page/:name`, the draft promotion `POST /api/v1/meta/page/:name/publish`, the package publish, and every in-process `saveMetaItem` caller on an environment's authoring channel), shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode). + + **The next save of a stored page whose components carry an undeclared, wrong-typed or retired property, or a reserved-namespace type the platform does not declare, is refused with the fix; stored pages keep serving until then.** The door judges writes only: a stored page row is not re-judged at boot, loads and is served as before, and a draft save is not judged until it is published. + + ## FROM → TO + + | you saved | the door now answers | write instead | + |:--|:--|:--| + | `page:header` with `properties: { titel: 'Deals' }` | `422`, `component-props-unknown-key` at `…properties.titel`, offering `title` | `properties: { title: 'Deals' }` | + | `record:related_list` with `properties: { limit: 'five' }` | `422`, `component-props-invalid`, naming the expected type | `properties: { limit: 5 }` | + | a component typed `page:headr` | `422`, `component-type-unknown`: "Rename `page:headr` → `page:header`." | `type: 'page:header'` | + | a component typed `acme:pipeline_board` (outside the reserved namespaces) | unchanged: saved | nothing | + + **The one-line fix: apply what the issue names, then save again.** If a component really does read the key, the props row in `@objectstack/spec` is what is wrong, not the page. + + **Who is affected.** A deployment whose stored pages carry such a finding: those pages keep loading and serving, and only their next save is refused until the finding is fixed. `objectstack migrate meta --stored` does not list these findings. The authored pages measured when the props findings became errors (37 pages in this repository and 8 in `objectstack-ai/hotcrm`) carried none, and the in-repo page corpus carries no `component-type-unknown` finding; deployed rows were not measured. The two former ADR-0087 D2 aliases on page component properties, `page:header` `description` (write `subtitle`) and an `element:button` inline `type: 'api'` action's object `params` (write `bodyExtra`), are retired from the load path at 18 and refused at every door with the rename (`objectstack validate`, load, and this save door), so the save door and `objectstack validate` agree on them. + + The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above (and the matching step-18 summaries) said a stored page still saves and loads, and now says it still loads and that its next save meets the component-props gate at the metadata save door. +- 1692cd1: feat(spec): the page form offers the list page's own empty state, `interfaceConfig.emptyState` + + Clause-②: no + + The console's list page draws the page's own `interfaceConfig.emptyState` in place of the source view's, so the page metadata form now offers it. + + - **`@objectstack/spec`**: `pageForm`'s Interface section gains an `emptyState` control under `interfaceConfig`, a popover of the list view's `EmptyStateSchema` keys (`title`, `message`, `icon`, `link`), placed after the add-record entry point. No schema changes: `InterfacePageConfigSchema.emptyState` already parsed, so every page that was valid before is valid now, and nothing new is accepted. + - **`@objectstack/platform-objects`**: the metadata-forms translation bundles carry the new control's label and help text, translated in `zh-CN`, `ja-JP` and `es-ES`. +- 73c7dc4: The `PermissionSet.description` liveness row cites its real reader again: Studio's permission-set list renders the authored `description` as a column, read off `GET /api/v1/meta/permission` + + Clause-②: no (the ledger row's evidence is rewritten and its status stays `live`: `PermissionSetSchema` accepts `description` exactly as before, and no state count moves) + + - `liveness/permission.json`: the `description` row's evidence named two readers that are gone, the `sys_permission_set` row object and the permission-set projection. It now cites objectui's `permission` metadata resource, whose list declares a `description` column, and the generic metadata list page that renders it. It also cites the metadata door that serves the authored body to that page. `verifiedAt` moves to 2026-10-14, and the row is marked `evidenceScope: cross-repo`. + - `liveness/permission.json`: the `label` row's evidence named the record title of the `sys_permission_set` row object, which is gone. It now cites the same `permission` metadata resource's `label` list column, the same list page and the same metadata door. `verifiedAt` moves to 2026-10-14, and the row is marked `evidenceScope: cross-repo`. Its status stays `live`. +- 2372e0a: Component-member read points, part 1 of 3: the doc comments in the first half of `src/ui/component.zod.ts` no longer cite objectui files, lines or commits as read points. + + Clause-②: no + + - The doc comments of the `ComponentPropsMap` rows from the `page:*` and `record:*` blocks through the `element:*` and `action:*` rows, object-grid, object-metric and the first part of object-kanban now say "read points recorded in `liveness/page.json`, row `regions`". The per-hop re-read chains are removed. What each record claims about the renderer is kept in the shorter comment. + - `liveness/page.json`, row `regions`, gains one evidence list in its `note`: 120 entries spelled `COMPONENT.member: objectui @SHA FILE#SYMBOL`. The list asserts no verdict, so component-level liveness stays unclassified. All three parts of this change carry the same list. + - No schema, type, export, default, accept or refuse change. No `.describe()` text changed. +- 5afa1e0: Component-member read points, part 2 of 3: the doc comments in the second half of `src/ui/component.zod.ts` no longer cite objectui files, lines or commits as read points. + + Clause-②: no + + - The doc comments of the `ComponentPropsMap` rows from the rest of object-kanban through object-calendar, object-form, object-master-detail-form, object-map, object-gantt, object-tree and object-timeline, and the row registrations at the end of the file now say "read points recorded in `liveness/page.json`, row `regions`". The per-hop re-read chains are removed. What each record claims about the renderer is kept in the shorter comment. + - `liveness/page.json`, row `regions`, gains one evidence list in its `note`: 120 entries spelled `COMPONENT.member: objectui @SHA FILE#SYMBOL`. The list asserts no verdict, so component-level liveness stays unclassified. All three parts of this change carry the same list. + - No schema, type, export, default, accept or refuse change. No `.describe()` text changed. +- 56b2612: Component-member read points, part 3 of 3, plus the two held-back moves and the other citation spellings: `packages/spec/src` no longer ties a live read point to an objectui commit or to the console pin. + + Clause-②: no + + - **Component-member tests and the other group-C files.** `component.test.ts`, the `component-*.pin.test.ts` family, the record-block tests, `expression-bindable-text-keys.zod.ts`, `page.zod.ts` and `inline-grid-column-carriers.test.ts` now point at `liveness/page.json`, row `regions`. The evidence list there is the same one parts 1 and 2 carry. `content/docs/references/ui/expression-bindable-text-keys.mdx` is regenerated from the shortened comment. + - **The two held-back moves.** The `measures.format` record in `src/ui/dataset.zod.ts` moves into `liveness/dataset.json`, row `measures.format`. The `enable.apiMethods` record in `api-methods-batch-conformance.test.ts` moves into `liveness/object.json`, row `enable.apiMethods`. + - **Other spellings.** Live read points written as `objectui SHA … File.tsx:NNN`, or sha-less as "at the `.objectui-sha` pin", move into the note of their row in `field.json`, `action.json`, `app.json`, `dashboard.json`, `view.json` or `page.json`, each as `objectui @SHA FILE#SYMBOL`. + - **Unchanged on purpose.** Retirement history stays where it is: the ADR-0087 conversion entries, the v18 migration entries and the retirement pins. A citation with no matching ledger row also stays, and is listed on the pull request. + - No schema, type, export, default, accept or refuse change. No `.describe()` text changed. +- 1418de4: The spec source no longer ties its objectui read-point records to the console pin. A console pin bump now changes no `packages/spec/src` text unless a key's behaviour changes. + + Clause-②: no + + - **Pin-tied citations are gone.** Every citation that said it named the objectui commit this repository builds against (the 49 written `` `.objectui-sha` = ``) now states the commit it was read at, `b9814b334`, and no longer claims to follow the pin. Six of them are inside the `reason` text of v18 semantic migration entries (`kernel-runtime-config-timeout-unit-in-key`, `kernel-health-check-and-hot-reload-durations-unit-in-key`, `logging-durations-unit-in-key`, `system-metrics-jsdoc-durations-unit-in-key`, `system-tracing-otel-exporter-durations-unit-in-key`, `tenant-schema-cache-ttl-unit-in-key`). Only that wording changes there. The measurement each entry reports is unchanged. + - **Read points that have a liveness-ledger row moved into it.** 38 citations moved out of doc comments and test headers into the `note` of their row in `packages/spec/liveness/*.json`. That covers the view-binding completeness table, the list-view map, chart and form-span records, action `outcomeMessages` / `onSuccess` / `aria`, dashboard widgets and chart `aria`, joined reports, field `options` / `scale` / `inlineColumns`, and translation `flows`. Each moved note names the renderer by symbol, as `objectui @ #`, never by line number. The long per-hop re-read chains are dropped. What each record claims is kept, either in the ledger note or in the shortened doc comment. + - **No schema, type, export, default or refusal changes.** The one `.describe()` that changed is the form-field `span` text. It now gives the renderer's behaviour without naming a commit, and `content/docs/references/ui/view.mdx` was regenerated to match. + - Component-member read points (`ComponentPropsMap`), the dataset measure `format` and `enable.apiMethods` records, and retirement-history citations still carry their dated objectui commits in the source. A later change handles them. +- 7a96bf8: The sha-less objectui `File.tsx:NNN` read points that have a liveness row leave the published spec source and move into that row. Two live citations with no ledger row stay in `packages/spec/src`, named under "Unchanged on purpose" below. + + Clause-②: no + + - **Sha-less line citations.** Ten live read-point records named an objectui file by line, with no SHA. Each moves into the note of its liveness row as `objectui @SHA FILE#SYMBOL`, re-read at objectui `b9814b334`, and the source comment now names the row: `data/field.zod.ts` (`field.json`, rows `step` and `dimensions`), `ui/action.zod.ts` and `system/translation.zod.ts` (`action.json`, row `description`), `ui/bulk-action.zod.ts` (`view.json`, row `list.bulkActionDefs`), `ui/view.zod.ts` (`view.json`, rows `list.filter`, `list.sort` and `list.userFilters`) and `ui/i18n-label-resolver.ts` (`page.json`, row `regions`). + - **Two stale renderer claims are no longer stated.** For an action bound to an object, the console's description resolver no longer falls back from `objects.{object}._actions.{action}.description` to `globalActions.{action}.description`. objectui's bulk-param option type is no longer closed. The comments in `ui/action.zod.ts`, `system/translation.zod.ts` and `ui/bulk-action.zod.ts` said both. They now state only the contract, and the ledger records the current reading. + - **`liveness/view.json`, row `list.exportOptions`** names its reader by symbol instead of by line. + - **Unchanged on purpose.** Retirement history stays where it is: the `areas[].order` record in `ui/app.zod.ts` and the `widgets[].aria` record in `ui/dashboard.zod.ts`. Two citations with no ledger row also stay: the build-progress phase reading in `ai/build-progress.zod.ts`, and the "pin this view" trace on `ViewItemSchema` in `ui/view.zod.ts`. + - No schema, type, export, default, accept or refuse change. No `.describe()` text changed. +- 57994c3: fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft a draft save that names no package writes, and are refused `409 RESOURCE_CONFLICT` where that save is; the draft save's receipt names the package it inherits (ADR-0048, ADR-0070) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `sys_metadata` row is keyed by its package (ADR-0048), so one item can hold a draft per package. A draft save that names no package (`PUT /api/v1/meta/TYPE/NAME?mode=draft`) writes the draft of the package the item's active rows are bound to, and the strict draft read and the draft preview that name no package serve that draft. The other two draft doors did not take that address. + + **FROM.** Over an item whose active rows are bound to package `P`: + + - With a package-unbound draft stored beside `P`'s draft, `POST /api/v1/meta/TYPE/NAME/publish` promoted whichever draft the database returned first. On SQLite that is the package-unbound one, and it was promoted into a package-unbound active row that no read naming no package serves. The publish answered `200`, and the item read kept serving `P`'s row unchanged. + - With the only draft bound to another package `Q` (saved with `?mode=draft&package=Q`), the strict draft read answered `404 NO_DRAFT`, but the publish promoted `Q`'s draft into a second package's active row. After that, every save and delete of the item that names no package was refused `409`. `DELETE …?state=draft` removed `Q`'s draft. + - With only a package-unbound draft, the publish promoted it into a package-unbound active row beside `P`'s, which no read naming no package serves. + - With drafts bound to two or more packages, `DELETE …?state=draft` was refused `409 RESOURCE_CONFLICT`. The discard took its address from the draft rows, not from the active rows the draft save inherits from. An item never published into a package, with a package-unbound draft beside two or more packages' drafts, was refused the same way. + + Over an item whose active rows two packages hold, the publish naming no package promoted the first draft it met, and the discard removed the draft of the one package the drafts were bound to, while the draft save naming no package was refused `409`. A draft save naming no package that inherited `P` answered "Saved … (env-wide, state=draft)", without the package. + + **TO.** A per-item publish and a draft discard that name no package act on the draft the draft save naming no package writes: + + - **Active rows bound to one package `P`.** They act on `P`'s draft, else the package-unbound draft. A publish promotes either one into `P`'s active row, the row the read naming no package serves next. A package-unbound draft is adopted into `P`, as the draft save adopts it. When neither exists, the publish answers `404 NO_DRAFT`, as the strict draft read does. Its message names the other packages' drafts and the remedy, `?package=`. The discard removes nothing (`reset: false`) and its receipt names the drafts that remain. + - **Active rows bound to two or more packages.** Both are refused `409 RESOURCE_CONFLICT` and change nothing, as the draft save naming no package is. The message names the packages and the remedy, `?package=`. + - **An item never published into a package.** Both act on the package-unbound draft, the one the draft save writes. If there is none, they act on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts. + + The draft save's receipt names the package the draft went into: "Saved view 'my_view' (env-wide, package=com.acme.app, state=draft)". A draft save into the package-unbound row reads as before. + + **Unchanged.** A publish or a discard that names its package (`?package=`, `packageId` in process). A publish stating `packageId: null` in process, which still pins the package-unbound draft. The batch publish and discard of a package's drafts (`POST /packages/:id/publish-drafts`, the discard of a package's drafts). No stored row is rewritten or removed by the upgrade. + + **Remedy.** For an item two or more packages hold, or to publish or discard a draft bound to a package other than the item's own, send the request with `?package=` naming that package (`packageId` in process). A client that compares draft-save receipt text should expect `, package=` after `env-wide` when the draft inherits a package. + + `@objectstack/spec`: the `PublishMetaItemRequestSchema.packageId` description now states what a publish naming no package promotes, where it said "match any package"; the `DeleteMetaItemRequestSchema.packageId` description states what a draft discard naming no package removes. +- 23e689d: fix(plugin-security,spec): the unnamed-grant boot line and the `ups-permission-set-id-column-retired` upgrade entry name the reviewed-plan ceremony, `--out plan.json` then `--apply --backup-taken --plan-file plan.json`, instead of a bare `--apply` the CLI refuses (ADR-0131 D10) + + Clause-②: no + + `os migrate organization-ownership --apply` refuses a run without `--plan-file`, the file a plan run wrote and the operator reviewed. Two remedy texts still told the operator to run `--apply` alone, which answers `plan-file-required` and writes nothing: + + - `@objectstack/plugin-security`: the `kernel:bootstrapped` catalog reference report's error line for grants that name no permission set (`permission_set` empty). + - `@objectstack/spec`: the `replacement` of the ADR-0087 entry `ups-permission-set-id-column-retired`, the row the upgrade guide prints for the retired `sys_user_permission_set.permission_set_id`. + + Both now name the two steps: plan first, `os migrate organization-ownership --out plan.json`, review the file it writes, then `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, which executes that plan; restart afterwards. The ceremony itself is unchanged: its rewrite names each legacy grant from the retired id, and its column-drops step drops the two retired indexes and then the column. Only the text changes; the line's level, its fields and the entry's `id` are as they were. +- 4feb2d0: fix(metadata-protocol): every write over a sealed, package-held name reads one sentence that states what the door knows; the inferred create remedy retires + + Clause-②: no + + + + **BREAKING** (a public method-signature narrowing in `@objectstack/metadata-protocol`), shipped as `minor` under the launch-window convention for breaking changes. The `'create'` member of `ObjectStackProtocolImplementation.packagedBaseRefusal`'s `operation` (now `'save' | 'delete'`) and the optional `operation` parameter of `tenantAuthoredWriteRefusal` retire. A caller that passed `operation: 'create'` passes `'save'` (or omits `operation` on `tenantAuthoredWriteRefusal`): the verdict, the code and the status were always the same. + + **What was wrong.** A save over a name a managed package holds, on a type with no environment overlay, is refused `403 NOT_OVERRIDABLE`. The sentence guessed what the save meant from `If-None-Match: *`. A publish-mode `*` was told "… so its name is taken. To author your own TYPE, choose a name no package or built-in holds …", and every other save, the console's generic create (`?mode=draft` with `If-None-Match: *`) included, was told "… Edit the source artifact and redeploy." Neither request declares a create or an edit, so each remedy was wrong for one of its callers. An editor who followed the first authored a second item. A creator who followed the second, and every administrator of an installed package, was sent to an artifact they cannot edit. + + **What changed.** + + - `@objectstack/metadata-protocol`: every write over such a name, on a type with no regime row, reads one sentence, whatever its mode or pin: "'TYPE/NAME' is provided by a managed package and is sealed: it cannot be written here. GET /api/v1/meta/TYPE lists the names in use." The same sentence comes from the repository's type door (draft promotion, restore, revert). It gives no create advice and no source-artifact advice. It names no holding package, because neither door has the package at hand. A type with a regime row (`flow`, `action`, `permission`, `datasource`) keeps its regime sentence, and every removal keeps its sentence. + - `@objectstack/metadata-protocol` (retired, no producer remains): the two signature members named under **BREAKING** above, and the `create` arm of the internal `SealedItemOperation`. + - `@objectstack/spec`: the `GetMetaItemResponseSchema.version` and `MetadataConflictErrorSchema.currentVersion` descriptions say that a `null` beside `editable: false` takes no save, and prescribe no `If-None-Match: *` pin for it. + - `@objectstack/runtime`: a permission-set clone (`POST /api/v1/security/permission-sets/:name/clone`) whose save loses the race for the target name now answers the door's own `409` name-taken refusal ("Permission set 'NAME' already exists — a clone must take a NEW machine name. …", code `RESOURCE_CONFLICT`, where it used to answer code `METADATA_CONFLICT`), the same refusal a name the catalog already held reads. It used to relay the `/meta` door's conflict sentence, which told the caller to send `If-Match`, and this door takes no `If-Match`. + + **Unchanged.** Which requests are accepted or refused. The `NOT_OVERRIDABLE` code and `403` status. The named read-only base's `ITEM_LOCKED` sentence. The `/meta` door's conflict sentence for its own callers. +- 1ac24f2: fix(spec): `translateView` localizes a form view's `sections` through `objects.OBJECT._sections.NAME` (#23632) + + A served form view (`viewKind: 'form'`) carries its sections at `config.sections`. `translateView`, and `translateMetadataDocument('view', …)` that dispatches to it, translated the view's `label`, `description` and `config.bulkActionDefs`, and never a section. So a reader that translates on the caller's behalf served every section heading as authored, beside translated field labels. The anonymous public form is one: its visitor cannot load the translation bundle. + + Each named section's `label` and `description` now resolve through `objects.OBJECT._sections.NAME.label` / `.description`, the key `TranslationDataSchema` already declares and `os i18n extract` already scaffolds. They use the same locale chain as every other resolver here (requested locale, then the declared `fallbackChain`, never walked for the `defaultLocale`): + + - A missing key keeps the authored text. + - A section with no `name` has no key and is served exactly as authored. + - Only authored copy is translated. A section that authors no `label` or no `description` gains neither from the bundle. + - The form's own `data.object` binding decides the object, before the view's, as the extractor and the translation linter already bind it. + - An explicit override beats the catalog, by the rule the view's other strings follow (ADR-0029 D9.2a). Given `packagedBase`, a section whose string diverged from the packaged section of the same `name` keeps its string. A section the base does not carry keeps its strings. + + No export, parameter or type changes. Every caller of `translateView` / `translateMetadataDocument('view', …)` now receives a form view's section copy in the requested locale. + + Clause-②: no +- c03077e: fix(metadata-protocol)!: a draft save that names no package, of an item never published into a package, writes the draft the publish and the discard naming none act on, and is refused `409 RESOURCE_CONFLICT` where they are; the draft discard's receipt names the package (ADR-0048, ADR-0070) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `sys_metadata` row is keyed by its package (ADR-0048), so an item can hold a draft per package. Over an item never published into a package (no active row bound to one), the per-item publish (`POST /api/v1/meta/TYPE/NAME/publish`) and the draft discard (`DELETE /api/v1/meta/TYPE/NAME?state=draft`) that name no package act on the package-unbound draft, else on the one package's draft, and are refused `409 RESOURCE_CONFLICT` when two or more packages hold drafts. The draft save naming no package did not take that address. + + **FROM.** Over such an item, `PUT /api/v1/meta/TYPE/NAME?mode=draft` with no `?package=`: + + - With the only draft bound to package `Q` (saved with `?mode=draft&package=Q`), the save wrote a package-unbound draft beside `Q`'s and answered `200` ("Saved … (env-wide, state=draft)"). The drafts became `[package-unbound, Q]`, and from then on the publish and the discard naming no package acted on the package-unbound one, while `Q`'s draft stayed pending. + - With drafts bound to two or more packages and no package-unbound draft, the save wrote a package-unbound draft beside them and answered `200`, while the publish and the discard naming no package were refused `409`. + - `DELETE …?state=draft` of a package's draft answered "Draft discarded — TYPE/NAME", without the package. + + **TO.** + + - With the only draft bound to `Q`, the save updates `Q`'s draft and its receipt names the package ("Saved view 'my_view' (env-wide, package=Q, state=draft)"). The strict draft read's `version` (`GET …?state=draft`) is that draft's, so a save pinned to it with `If-Match` is accepted; the publish and the discard naming no package act on the same draft. + - With drafts bound to two or more packages and no package-unbound draft, the save is refused `409 RESOURCE_CONFLICT` and writes nothing, as the publish and the discard are. The message names the packages and the remedy, `?package=`. + - A draft discard of a package's draft names the package: "Draft discarded (package=Q) — TYPE/NAME. [seq=N]". The discard of a package-unbound draft reads as before. + + **What follows from the save writing `Q`'s draft**, over an item never published into a package whose only draft is bound to `Q`, for a draft save naming no package: + + - FROM `If-Match` carrying `Q`'s draft token (the version `GET …?state=draft&package=Q` serves) refused `409 METADATA_CONFLICT`, TO accepted: that draft is the row the save writes, so the strict draft read naming no package now serves the same token. + - FROM `If-None-Match: *` (a first-write pin) creating a package-unbound draft and answering `200`, TO `409 METADATA_CONFLICT` with nothing written: `Q`'s draft is the row the save would overwrite. + - FROM a permission set whose body declares `packageId: Q` refused `400 VALIDATION_ERROR` (the owner binding of ADR-0086 D3, judged against a save "into no package"), TO accepted: the binding the write resolves is `Q`. A body declaring another package is still refused, and the refusal now names `Q`. + + `@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states the draft save's address in the words the publish's and the discard's descriptions use: the draft of the one package the item's active rows are bound to, else its package-unbound draft; for an item never published into a package, the package-unbound draft, else the one package's draft; refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages. It said the save wrote "the env-local draft" whenever the active row was not bound to a package. No key, type, enum or default moves. + + **Unchanged.** A draft save that names its package (`?package=`, `packageId` in process). An item with an active row bound to a package, whose draft save naming no package still inherits that package. An item with a package-unbound draft, which a draft save naming no package still updates, whatever packages' drafts sit beside it. A brand-new item with no draft at all, whose first draft save naming no package writes the package-unbound draft. No stored row is rewritten or removed by the upgrade. + + **Remedy.** For an item two or more packages hold drafts of, send the draft save with `?package=` naming the package (`packageId` in process). A client that compares draft-discard receipt text should expect ` (package=ID)` after `Draft discarded` when the draft discarded is bound to a package. +- 069fd40: fix(metadata-protocol): the strict draft read naming no package (`GET /api/v1/meta/TYPE/NAME?state=draft`) serves its content from the draft its `version` names, the draft the draft save, the per-item publish and the draft discard naming no package act on (ADR-0048, ADR-0070) + + Clause-②: yes + + A `sys_metadata` row is keyed by its package (ADR-0048), so an item never published into a package can hold a package-unbound draft beside package `Q`'s. The strict draft read naming no package answered its `version` from the package-unbound draft (the draft the save naming no package writes), but its content from whichever draft row the store returned first. On a store that returned `Q`'s row first, it served `Q`'s body under the package-unbound draft's token, and a save pinned to that token (`If-Match`) wrote the package-unbound draft, a different row from the one read. + + The read now takes its content from the same address as its `version`, in every storage order: the package-unbound draft when one is stored, else the one package's draft; for an item whose active rows are bound to one package, that package's draft as before. The draft-overlay preview naming no package (`previewDrafts`) serves the same draft. Drafts bound to two or more packages with no package-unbound draft are still served best-effort, with `version: null`, as before (the save naming no package is refused there). No accept set changes: no request that was answered `200` is refused now, and `?package=` still reaches each package's draft. + + `@objectstack/spec`: the description of `PublishMetaItemRequestSchema.packageId` now says, as `SaveMetaItemRequestSchema.packageId`'s does, that a publish naming no package is refused for drafts bound to two or more packages only when no package-unbound draft is stored. Description only. + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 5475ed07b31..b0f009aa866 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 8f0e3de7f5f..91e1599e68b 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/trigger-api +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 321bdb1caac..932d1570ed3 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md index 44bf72cd6be..9b4ffe83be2 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/plugin-trigger-record-change +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index fb477eccce7..f692a071806 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": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md index 72e5011a9be..5af833ee9e6 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/plugin-trigger-schedule +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/metadata-core@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + ## 18.0.0-next.1 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index c19d19d3de5..e6ccf57eaf1 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack \u2014 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 818b1d15ecf..0c6f3e5a655 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/types +## 18.0.0-next.2 + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [584e737] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [0f05907] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [4feb2d0] +- Updated dependencies [1ac24f2] +- Updated dependencies [462dff3] +- Updated dependencies [c03077e] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] + - @objectstack/spec@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 25d14653666..bd40d99c9fd 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Shared interfaces describing the ObjectStack Runtime environment", "main": "dist/index.js", diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index 7ab91ef5194..89b4cb30d8c 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,151 @@ # @objectstack/verify +## 18.0.0-next.2 + +### Minor Changes + +- be16feb: feat(verify): `flows.run` takes the system principal, `{ system: true }`, and starts the flow the way its own trigger does + + Clause-②: yes + + `flows.run(name, params, { system: true })` is a new overload beside `flows.run(name, params, { as })`. Nothing is removed, and the `{ as }` overload answers as before. + + - **What it does.** It calls the booted `automation` service's own `execute(name, { params, callerParamKeys: [] })`, in process. That is the call the schedule trigger's tick makes when it fires a flow. No HTTP route is involved, and no caller is resolved, admitted or refused. The engine applies the flow's own `runAs`, as it does for a run its trigger fired. + - **Why.** The trigger route, `POST /automation/:name/trigger`, refuses a self-triggered flow declared `runAs: 'system'` (`type` `autolaunched`, `record_change` or `schedule`) to every caller, the platform admin included, with `PERMISSION_DENIED` / `403`. That refusal is unchanged. So an app had no kit door to start its own scheduled flow in a test, and had to call the automation service by hand. + - **What it answers.** The same `FlowRun` the `{ as }` overload answers: the engine's result unchanged, plus `flowName`. A run that paused goes straight back to `flows.resume`. The engine answers a run that did not succeed with a result, not a throw, and this overload hands that result back unchanged: `success: false`, with `status: 'failed'` for a run that failed, a `code` for a refusal before dispatch (such as `FLOW_DISABLED`), and no `code` for an unknown flow name. Assert on `success` before asserting on the flow's side effects. + - **What the context leaves out.** `params` are the trigger's seeds: like the tick, the context names no caller-supplied keys, so a `screen` node takes none of them as its submission. The context carries no `event`, no user and no organization. A schedule binding that declares an acting organization runs as that organization on a real tick; this overload does not read the binding. + - **One caller.** A call that names both `as` and `system: true` is refused with `INVALID_REQUEST` / `400` before anything runs, as `hooks.run` refuses it. + + ```ts + const run = await stack.flows.run('case_sla_monitor', {}, { system: true }); + expect(run.success).toBe(true); + + // The trigger door still refuses a person, the admin included. + await expect(stack.flows.run('case_sla_monitor', {}, { as: admin })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + ``` + +### Patch Changes + +- Updated dependencies [0d3ea29] +- Updated dependencies [f9b4217] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [cb9841a] +- Updated dependencies [b60eb78] +- Updated dependencies [584e737] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [5547ad4] +- Updated dependencies [94924e0] +- Updated dependencies [0f05907] +- Updated dependencies [05e3b8e] +- Updated dependencies [086fa46] +- Updated dependencies [c245986] +- Updated dependencies [0be162c] +- Updated dependencies [fc1a55a] +- Updated dependencies [fc1a55a] +- Updated dependencies [b1e9280] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [e1cefa5] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [73c7dc4] +- Updated dependencies [8693110] +- Updated dependencies [ad5a7d7] +- Updated dependencies [dd29bbe] +- Updated dependencies [dd29bbe] +- Updated dependencies [6f21216] +- Updated dependencies [2372e0a] +- Updated dependencies [5afa1e0] +- Updated dependencies [56b2612] +- Updated dependencies [1418de4] +- Updated dependencies [7a96bf8] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [d1e2841] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [b1cfa3d] +- Updated dependencies [23e689d] +- Updated dependencies [0fb55fc] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [bc0538a] +- Updated dependencies [d176b0a] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [a6d0715] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [9f81260] +- Updated dependencies [e611e2b] +- Updated dependencies [b513343] +- Updated dependencies [1ac24f2] +- Updated dependencies [30c33e3] +- Updated dependencies [7e6e4e1] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [69351e9] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [941c78a] + - @objectstack/plugin-security@18.0.0-next.2 + - @objectstack/platform-objects@18.0.0-next.2 + - @objectstack/spec@18.0.0-next.2 + - @objectstack/core@18.0.0-next.2 + - @objectstack/rest@18.0.0-next.2 + - @objectstack/runtime@18.0.0-next.2 + - @objectstack/service-mailbox@18.0.0-next.2 + - @objectstack/objectql@18.0.0-next.2 + - @objectstack/plugin-audit@18.0.0-next.2 + - @objectstack/plugin-auth@18.0.0-next.2 + - @objectstack/plugin-sharing@18.0.0-next.2 + - @objectstack/service-datasource@18.0.0-next.2 + - @objectstack/service-settings@18.0.0-next.2 + - @objectstack/service-storage@18.0.0-next.2 + - @objectstack/plugin-email@18.0.0-next.2 + - @objectstack/plugin-hono-server@18.0.0-next.2 + - @objectstack/mcp@18.0.0-next.2 + - @objectstack/metadata@18.0.0-next.2 + - @objectstack/service-automation@18.0.0-next.2 + - @objectstack/plugin-approvals@18.0.0-next.2 + - @objectstack/plugin-webhooks@18.0.0-next.2 + - @objectstack/service-job@18.0.0-next.2 + - @objectstack/service-messaging@18.0.0-next.2 + - @objectstack/service-queue@18.0.0-next.2 + - @objectstack/service-realtime@18.0.0-next.2 + - @objectstack/service-analytics@18.0.0-next.2 + - @objectstack/service-cache@18.0.0-next.2 + - @objectstack/service-package@18.0.0-next.2 + - @objectstack/service-sms@18.0.0-next.2 + - @objectstack/trigger-api@18.0.0-next.2 + - @objectstack/trigger-record-change@18.0.0-next.2 + - @objectstack/trigger-schedule@18.0.0-next.2 + - @objectstack/types@18.0.0-next.2 + - @objectstack/plugin-pinyin-search@18.0.0-next.2 + ## 18.0.0-next.1 ### Minor Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index d65063a5200..e99556d63c3 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module", diff --git a/skills/objectstack-ai/SKILL.md b/skills/objectstack-ai/SKILL.md index 8656a3c4adf..000637f0d88 100644 --- a/skills/objectstack-ai/SKILL.md +++ b/skills/objectstack-ai/SKILL.md @@ -12,7 +12,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: ai tags: agent, tool, skill, knowledge, llm, embedding, mcp --- diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index ae08f1a575e..4e83698be05 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -14,7 +14,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: api tags: rest, endpoint, auth, realtime, server --- diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 5a3535eebd7..e9affe94639 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -13,7 +13,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: automation tags: flow, workflow, trigger, approval, state-machine, scheduled, webhook --- diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 5223fcfaac0..be49e25a4be 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -15,7 +15,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: data tags: object, field, validation, index, relationship, hook, schema, permission, rls, security, seed, fixture --- diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 8a4651e9c0a..f4243bd6135 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -13,7 +13,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x and @objectstack/formula 18.x (CEL) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: expression tags: cel, formula, predicate, condition, validation, visibility, seed-dynamic --- diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index f96b448575c..ee2e5d7616d 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -13,7 +13,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: i18n tags: i18n, translation, locale, l10n, bundle, coverage --- diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 8aec97d58ea..c064193125c 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -17,7 +17,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x and @objectstack/core 18.x (Zod v4 schemas), Node 22+ metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: platform tags: project, defineStack, driver, hono, plugin, kernel, service, requires, capability, DI, lifecycle, cli, deploy, ops --- diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index 8cedad0b4ec..2ddc4313d2f 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -11,7 +11,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: query tags: query, filter, sort, paginate, aggregate, ObjectQL, full-text --- diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 34d51ffdde7..87dc86bc463 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -18,7 +18,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: ui tags: view, app, page, dashboard, report, chart, action, widget, doc --- diff --git a/skills/objectstack-upgrade/SKILL.md b/skills/objectstack-upgrade/SKILL.md index 3eb70dbc929..fdf54ef73da 100644 --- a/skills/objectstack-upgrade/SKILL.md +++ b/skills/objectstack-upgrade/SKILL.md @@ -16,7 +16,7 @@ license: Apache-2.0 compatibility: Requires `@objectstack/spec` and `@objectstack/cli` at the TARGET major; the chain replays from the spec's `MIGRATION_SUPPORT_FLOOR`. No network access required. metadata: author: objectstack-ai - version: "18.0.0-next.1" + version: "18.0.0-next.2" domain: process tags: upgrade, migration, protocol, major, retired-keys, tombstone, conversions, validate, report ---